diff --git a/csharp/App/Backend/Controller.cs b/csharp/App/Backend/Controller.cs
index afe5b474b..a6e38d3fd 100644
--- a/csharp/App/Backend/Controller.cs
+++ b/csharp/App/Backend/Controller.cs
@@ -1275,7 +1275,21 @@ public class Controller : ControllerBase
public ActionResult StopAlarmReviewCampaign()
{
AlarmReviewService.StopCampaign();
- return Ok(new { message = "Campaign stopped and progress file deleted. Safe to redeploy." });
+ return Ok(new { message = "Campaign paused — progress preserved. Use ResumeAlarmReviewCampaign to restart timers." });
+ }
+
+ [HttpPost(nameof(ResumeAlarmReviewCampaign))]
+ public ActionResult ResumeAlarmReviewCampaign()
+ {
+ AlarmReviewService.ResumeCampaign();
+ return Ok(new { message = "Campaign resumed — timers restarted from existing progress." });
+ }
+
+ [HttpPost(nameof(ResetAlarmReviewCampaign))]
+ public ActionResult ResetAlarmReviewCampaign()
+ {
+ AlarmReviewService.ResetCampaign();
+ return Ok(new { message = "Campaign fully reset — all progress deleted. Use StartAlarmReviewCampaign to begin again." });
}
[HttpGet(nameof(CorrectAlarm))]
diff --git a/csharp/App/Backend/Services/AlarmReviewService.cs b/csharp/App/Backend/Services/AlarmReviewService.cs
index f66fdcd96..ff278c407 100644
--- a/csharp/App/Backend/Services/AlarmReviewService.cs
+++ b/csharp/App/Backend/Services/AlarmReviewService.cs
@@ -206,8 +206,33 @@ public static class AlarmReviewService
Console.WriteLine("[AlarmReviewService] Daily scheduler started (8AM + 2PM jobs).");
}
- /// Stops the scheduler and deletes the progress file. Safe to call at any time.
+ /// Pauses the scheduler (timers stopped) but keeps all progress. Resume with ResumeCampaign().
public static void StopCampaign()
+ {
+ _morningTimer?.Dispose();
+ _afternoonTimer?.Dispose();
+ _morningTimer = null;
+ _afternoonTimer = null;
+ _testBatch = null;
+ Console.WriteLine("[AlarmReviewService] Campaign paused — progress preserved. Call ResumeCampaign() to restart timers.");
+ }
+
+ /// Resumes timers without touching progress. Use after StopCampaign() or server restart.
+ public static void ResumeCampaign()
+ {
+ if (!File.Exists(ProgressFile))
+ {
+ Console.WriteLine("[AlarmReviewService] No progress file found — use StartCampaign() to begin.");
+ return;
+ }
+ LoadGermanNames();
+ ScheduleTimer(ref _morningTimer, 8, 0, () => RunMorningJobAsync() .GetAwaiter().GetResult());
+ ScheduleTimer(ref _afternoonTimer, 14, 0, () => RunAfternoonJobAsync().GetAwaiter().GetResult());
+ Console.WriteLine("[AlarmReviewService] Campaign resumed — timers restarted.");
+ }
+
+ /// Deletes all progress and stops timers. Only use to start over from scratch.
+ public static void ResetCampaign()
{
_morningTimer?.Dispose();
_afternoonTimer?.Dispose();
@@ -217,8 +242,10 @@ public static class AlarmReviewService
if (File.Exists(ProgressFile))
File.Delete(ProgressFile);
+ if (File.Exists(CheckedFilePath))
+ File.Delete(CheckedFilePath);
- Console.WriteLine("[AlarmReviewService] Campaign stopped and progress file deleted.");
+ Console.WriteLine("[AlarmReviewService] Campaign fully reset — all progress deleted.");
}
private static void ScheduleTimer(ref Timer? timer, int hour, int minute, Action action)
@@ -265,6 +292,15 @@ public static class AlarmReviewService
{
var submissionCount = current.Submissions.Values.Count(s => s != null);
+ // If the batch was sent today, don't treat 0 submissions as a stall —
+ // reviewers haven't had a full day yet. Skip and wait for tomorrow's job.
+ var sentToday = current.SentDate == DateTime.Now.ToString("yyyy-MM-dd");
+ if (submissionCount == 0 && sentToday)
+ {
+ Console.WriteLine($"[AlarmReviewService] Batch {current.BatchNumber}: sent today, 0 submissions so far — waiting for tomorrow.");
+ return;
+ }
+
if (submissionCount == 0)
{
const int MaxResends = 3;
@@ -1457,6 +1493,46 @@ render();
var html = BuildReviewerEmailHtml(name, reviewUrl, batch.BatchNumber, batch.AlarmKeys.Count, quote, isResend);
await SendEmailAsync(email, subject, html);
}
+
+ // Notify admin with a preview of what reviewers received
+ await SendAdminBatchDispatchNoticeAsync(batch, isResend);
+ }
+
+ private static async Task SendAdminBatchDispatchNoticeAsync(BatchRecord batch, bool isResend)
+ {
+ var totalBatches = (int)Math.Ceiling((double)AllAlarmKeys.Length / BatchSize);
+ var action = isResend ? "erneut gesendet" : "gesendet";
+ var subject = $"[Admin] Stapel {batch.BatchNumber}/{totalBatches} {action} an {Reviewers.Length} Prüfer";
+
+ var previewUrl = $"{BaseUrl}/ReviewAlarms?batch={batch.BatchNumber}&reviewer={Uri.EscapeDataString(Reviewers[0].Name)}";
+ var reviewerList = string.Join(", ", Reviewers.Select(r => r.Name));
+
+ var alarmRows = string.Join("\n", batch.AlarmKeys.Select((key, i) =>
+ $"""
| {i + 1}. {SplitCamelCase(key)} ({(SinexcelKeySet.Contains(key) ? "Sinexcel" : "Growatt")}) |
"""));
+
+ var html = $"""
+
+
+ Stapel {batch.BatchNumber} {action}
+ {DateTime.Now:yyyy-MM-dd HH:mm} · {batch.AlarmKeys.Count} Alarme · Deadline: 8:00 Uhr morgen früh
+
+ Gesendet an: {reviewerList}
+
+
+ Formular ansehen →
+ (alle Prüfer sehen denselben Inhalt)
+
+
+ Alarme in diesem Stapel
+
+
+ Diese E-Mail dient nur zur Information — keine Aktion erforderlich.
+
+ """;
+
+ await SendEmailAsync(AdminEmail, subject, html);
}
private static async Task SendReminderEmailAsync(BatchRecord batch, string name, string email)