# Exchange Sync – QS-Protokoll > **Erstellt:** 2026-03-10 > **Abgeschlossen:** 2026-03-11 > **Status:** ✅ Vollständig implementiert > **Bearbeiter:** Entwicklung (VÖD) > **Branch:** `vöd/Exchange_Sync` --- ## Geänderte Dateien | Datei | Änderungsart | |-------|-------------| | `src/backend/Centron.BL/Sales/Calendar/ScheduleBL.cs` | Bugfixes + Refactoring | | `src/nexus/CentronNexus/ServiceBoard/Scheduler/SchedulerPage.razor` | Frontend-Fix Wochenanzeige | | `src/centron/Centron.WPF.UI/Modules/MyCentron/CentronInspectors/Inspectors/Schedule/OrphanedSeriesScheduleInspector.cs` | Neu – Inspector für verwaiste Serientermine | | `src/centron/Centron.WPF.UI/Modules/MyCentron/CentronInspectors/Inspectors/InspectorManager.cs` | Inspector registriert | | `src/centron/Centron.WPF.UI/Modules/MyCentron/CentronInspectors/Inspectors/Schedule/TimeChangedByGraphInspector.cs` | Namespace-Umbenennung (Time → Schedule) | | `src/centron/Centron.WPF.UI/Modules/MyCentron/CentronInspectors/Inspectors/Schedule/DuplicateMailEntryIdInspector.cs` | Namespace-Umbenennung (Time → Schedule) | --- ## Übersicht | # | Ticket | Problem | Schweregrad | Status | |---|--------|---------|-------------|--------| | 1 | [164020](#ticket-164020) | CenSU überschreibt fremde Outlook-Termine | 🔴 Kritisch | ✅ Implementiert | | 2 | [163184](#ticket-163184) | Doppelter Inhalt in Exchange-Termineinladung | 🔴 Kritisch | ✅ Implementiert | | 3 | [164121](#ticket-164121) | Gelöschte Serientermin-Instanzen bleiben in Nexus | 🟠 Hoch | ✅ Implementiert | | 4 | [160145](#ticket-160145) | Ganztages-Events um +1 Tag verschoben | 🟠 Hoch | ✅ Implementiert | | 5 | [158813](#ticket-158813) | Geplante Zeiten mit falschem Datum nach Sync | 🟠 Hoch | ✅ Implementiert | | 6 | [157036](#ticket-157036) | Fehlerhafte Wochenanzeige in Nexus | 🟡 Mittel | ✅ Implementiert | | 7 | [162826](#ticket-162826) | Outlook-Erinnerungsflut bei synchronisierten Zeiten | 🟡 Mittel | ✅ Implementiert | | 8 | [156991](#ticket-156991) | Doppelte Feiertage / falsches Datum in Nexus | 🟡 Mittel | ✅ Implementiert | | + | [Zusatz](#zusatz-serientermin-kaskade) | Gelöschter Serienmaster löscht Instanzen nicht | 🟠 Hoch | ✅ Implementiert | | + | [Inspector](#zusatz-inspector) | Manueller Cleanup verwaister Serientermine | — | ✅ Implementiert | --- ## Ticket 164020 **Betreff:** CenSU überschreibt fremde Outlook-Termine mit alten Zeitdaten ### Root Cause `SyncOldSchedule()` (Nexus → Outlook) hatte keinen Filter auf den Termintyp. Auch Helpdesk-Zeiterfassungs-Schedules (`ObjectType = HelpdeskTimerClass / HelpdeskClass`) wurden über diesen Pfad nach Exchange geschrieben – obwohl diese bereits über `CreateOrUpdateTimeSchedule()` verwaltet werden. Parallel lief `UpdateScheduleByGraph()` (Outlook → Nexus) und schrieb Outlook-Daten in Helpdesk-Schedules zurück (Datum/Uhrzeit), was zu einem Teufelskreis führte. ### Implementierter Fix **`SyncOldSchedule()` – Helpdesktypen ausschließen:** ```csharp baseExpression = baseExpression.And(x => x.ObjectType != (int)CentronObjectKindNumeric.HelpdeskTimerClass && x.ObjectType != (int)CentronObjectKindNumeric.HelpdeskClass); ``` **`UpdateScheduleByGraph()` – Datum-Schutz für Helpdesk-Schedules:** ```csharp if (!this.IsHelpdeskSchedule(schedule)) { // Datum nur für reine Outlook-Termine zurückschreiben schedule.DateStart = this.GetDateTimeFromGraphDateTime(graphEvent.Start, localTimeZone); schedule.DateEnd = this.GetDateTimeFromGraphDateTime(graphEvent.End, localTimeZone); } ``` **Neue Hilfsmethode `IsHelpdeskSchedule()`:** ```csharp private bool IsHelpdeskSchedule(Schedule schedule) => schedule.ObjectType == (int)CentronObjectKindNumeric.HelpdeskTimerClass || schedule.ObjectType == (int)CentronObjectKindNumeric.HelpdeskClass; ``` ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. Helpdeskzeit in Nexus anlegen (z.B. 11.03.2026, 09:00–10:00) | Zeit wird angelegt | | 2. Webservice-Sync abwarten (60s) | Zeit erscheint als Termin in Outlook mit korrektem Datum | | 3. Termin in Outlook manuell bearbeiten (Betreff ändern) | — | | 4. Webservice-Sync abwarten | **Datum der Nexus-Zeit bleibt 11.03.2026, 09:00–10:00** (kein Überschreiben durch Outlook) | | 5. Techniker legt weiteren eigenen Outlook-Termin an | Exchange-Termin bleibt unverändert – CenSU schreibt nicht in ihn | --- ## Ticket 163184 **Betreff:** Doppelter Inhalt in Exchange-Termineinladung (Body enthält Text 3× und altes Datum) ### Root Cause In `CreateOrUpdateTimeSchedule()` wurde `schedule.Caption = timer.ExternalNote` gesetzt, bevor `ReplaceVariables(body, schedule)` aufgerufen wurde. Das Body-Template enthält `@@TerminText@@`, welches intern `schedule.Caption` liest. Dadurch wurde `ExternalNote` doppelt in den Body eingebettet: einmal über `@@TerminText@@` und einmal direkt durch das Template `@@WorkOrTime@@`. ### Implementierter Fix ```csharp // Subject-Auflösung: Caption temporär setzen damit @@TerminText@@ im Subject funktioniert schedule.Caption = timer.ExternalNote; schedule.Subject = this.ReplaceVariables(subject, schedule); // Body-Auflösung: Caption leeren damit @@TerminText@@ im Body leer ist schedule.Caption = string.Empty; schedule.Caption = this.ReplaceVariables(body, schedule); ``` ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. Helpdeskzeit mit externem Hinweis anlegen (z.B. "Vor-Ort-Einsatz Drucker") | Zeit wird gespeichert | | 2. Sync abwarten | Outlook-Termin wird erstellt | | 3. Outlook-Termin öffnen, Body prüfen | Body enthält "Vor-Ort-Einsatz Drucker" **exakt einmal** | | 4. Nochmals Zeit speichern (Update) | Termin wird aktualisiert, Body enthält Text weiterhin nur einmal | --- ## Ticket 164121 **Betreff:** Gelöschte Serientermin-Instanzen bleiben in Nexus sichtbar ### Root Cause Microsoft Graph sendet bei gelöschten Serientermin-Instanzen kein `@removed`-Flag, sondern setzt `isCancelled = true`. Der bisherige Code prüfte nur `@removed` → gelöschte Instanzen wurden nie in Nexus als inaktiv markiert. ### Implementierter Fix **`StoreEvent()` – `isCancelled` gleichwertig zu `@removed` behandeln:** ```csharp bool isRemoved = graphEvent.AdditionalData.ContainsKey("@removed") || graphEvent.IsCancelled is true; ``` ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. In Outlook einen Serientermin anlegen (z.B. täglich, 5 Instanzen) | Alle 5 Instanzen erscheinen in Nexus nach Sync | | 2. Eine einzelne Instanz in Outlook löschen ("Nur diesen Termin") | — | | 3. Sync abwarten | **Die gelöschte Instanz verschwindet aus Nexus** | | 4. Verbleibende Instanzen prüfen | Übrige 4 Instanzen bleiben unverändert sichtbar | --- ## Ticket 160145 **Betreff:** Ganztages-Events in Nexus um +1 Tag verschoben ### Root Cause Microsoft Graph liefert Ganztages-Events mit `Start.TimeZone = "UTC"` und einem exklusiven Enddatum (`End = nächster Tag 00:00 UTC`). Die bisherige UTC→CET-Konvertierung verschob das Datum: `2025-08-16T00:00 UTC` → `2025-08-16T02:00 CEST`. Nexus interpretierte den 16.08 als Enddatum → Termin erschien auf dem falschen Tag. ### Implementierter Fix **`InsertScheduleByGraph()` und `UpdateScheduleByGraph()` – neue `ParseAllDayEventDates()`-Hilfsmethode:** ```csharp if (graphEvent.IsAllDay == true) (schedule.DateStart, schedule.DateEnd) = ParseAllDayEventDates(graphEvent); ``` ```csharp private static (DateTime Start, DateTime End) ParseAllDayEventDates(Event graphEvent) { var start = DateTime.Parse(graphEvent.Start.DateTime).Date; var end = DateTime.Parse(graphEvent.End.DateTime).Date.AddDays(-1); // exklusiv → inklusiv return (start, end); } ``` **Zusätzlich: IANA → Windows Timezone-Mapping in `GetDateTimeFromGraphDateTime()`:** ```csharp // "Europe/Berlin" wird korrekt auf "W. Europe Standard Time" gemappt if (TimeZoneInfo.TryConvertIanaIdToWindowsId(graphDateTime.TimeZone, out var windowsId)) graphTimeZone = TimeZoneInfo.FindSystemTimeZoneById(windowsId); ``` ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. In Outlook einen ganztägigen Termin am **15.08.2026** anlegen | Termin in Outlook korrekt am 15.08 | | 2. Sync abwarten | **Termin in Nexus erscheint am 15.08.2026** (nicht 16.08) | | 3. Termin in Outlook auf **10.–12.08.2026** (3 Tage) ändern | — | | 4. Sync abwarten | Nexus zeigt Termin vom **10.08. bis 12.08.** | --- ## Ticket 158813 **Betreff:** Geplante Zeiten erhalten nach Sync falsches Datum ### Root Cause `UpdateScheduleByGraph()` überschrieb `DateStart`/`DateEnd` von Helpdesk-Schedules mit den aus Exchange zurückgegeben Werten (Round-Trip-Problem). Zusätzlich: IANA-Zeitzonennamen (`"Europe/Berlin"`) wurden nicht als Windows-Zeitzone erkannt → unnötige Konvertierung, mögliche 1h-Abweichung je Sommerzeit. ### Implementierter Fix Beide Ursachen behoben: 1. **Helpdesk-Datum-Schutz** (identisch zu Ticket 164020) – `UpdateScheduleByGraph()` überschreibt Datum bei Helpdesk-Schedules nicht mehr. 2. **Timezone-Mapping** (identisch zu Ticket 160145) – IANA → Windows-ID, `HasSameRules()`-Prüfung verhindert unnötige Konvertierungen bei gleicher Zeitzone. ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. In Nexus eine Planung anlegen: **02.03.2026, 08:00–09:00** | Nexus zeigt 02.03.2026 | | 2. Sync abwarten (Nexus → Outlook) | Outlook zeigt Termin am 02.03.2026 korrekt | | 3. Erneuten Sync abwarten (Outlook → Nexus, Delta-Rückweg) | **Nexus zeigt weiterhin 02.03.2026** (kein Überschreiben) | | 4. Prüfen ob Uhrzeit korrekt bleibt | 08:00–09:00 unverändert | --- ## Ticket 157036 **Betreff:** Fehlerhafte Wochenanzeige in Nexus (Arbeitswoche zeigt nur Freitag) ### Root Cause Der `DxSchedulerWorkWeekView` in `SchedulerPage.razor` hatte kein `WorkDays`-Attribut. Ohne diese Angabe verwendet DevExpress einen internen Default, der in dieser Version nur den letzten Werktag (Freitag) anzeigte. ### Implementierter Fix **`SchedulerPage.razor` – `WorkDays`-Attribut ergänzt:** ```razor ``` ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. Nexus öffnen → Zeitplanung → Ansicht "Arbeitswoche" wählen | **Alle 5 Werktage (Mo–Fr) werden angezeigt** | | 2. Termin am Mittwoch prüfen | Termin erscheint korrekt auf Mittwoch | | 3. Ansicht wechseln (Woche, Monat) und zurück zu Arbeitswoche | Arbeitswoche zeigt weiterhin Mo–Fr | --- ## Ticket 162826 **Betreff:** Outlook-Erinnerungsflut bei synchronisierten Helpdeskzeiten ### Root Cause `UpdateTerminplanungPersonTable()` → `AddScheduleToExchange()` setzte `isReminderOn` nicht explizit auf `false` für Helpdesk-Zeiten. Outlook aktiviert standardmäßig eine 15-Minuten-Erinnerung für alle neuen Termine. ### Implementierter Fix **`UpdateTerminplanungPersonTable()` – Reminder für Helpdesk-Schedules deaktivieren:** ```csharp bool? reminderOn = this.IsHelpdeskSchedule(schedule) ? false : (bool?)null; var result = await this.AddScheduleToExchange(schedule, schedulePerson, isReminderOn: reminderOn); ``` `null` bedeutet: Reminder-Einstellung nicht ändern (Standard-Outlook-Verhalten für normale Termine). ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. Helpdeskzeit speichern → Sync abwarten | Termin in Outlook erscheint | | 2. Outlook-Termin öffnen → Eigenschaften prüfen | **Feld "Erinnerung" ist deaktiviert** (kein Häkchen) | | 3. Normalen Nexus-Kalendertermin anlegen → Sync | Erinnerung bleibt auf Standard (15 Min.) | --- ## Ticket 156991 **Betreff:** Doppelte Feiertage / falsches Datum in Nexus ### Root Cause **A) Datumsversatz:** Identisch zu Ticket 160145 (UTC-Konvertierungsfehler bei `IsAllDay = true`). **B) Duplikate:** Wenn derselbe Feiertag in mehreren Exchange-Kalendern (z.B. persönlicher Kalender + Firmenkalender) enthalten ist, liefert Graph Delta denselben Feiertag mit unterschiedlichen `MailEntryID`s. Der bisherige Code prüfte nur auf `MailEntryID`-Duplikate → zwei identische Schedules wurden angelegt. ### Implementierter Fix **A)** UTC-Fix identisch zu Ticket 160145 über `ParseAllDayEventDates()`. **B) Duplikat-Prüfung in `StoreEvent()` für Ganztages-Events:** ```csharp if (graphEvent.IsAllDay == true && graphEvent.Subject is not null) { var (allDayStart, _) = ParseAllDayEventDates(graphEvent); var existingAllDay = GetSchedulesByExpression(x => x.Subject == graphEvent.Subject && x.DateStart == allDayStart && x.FullDay == true && x.IsActive); if (existingAllDay.Any(s => GetSchedulePersonsByFilter( new SchedulePersonsFilter { ScheduleI3D = s.I3D }) .Any(sp => sp.PersonalI3D == employee.I3D))) return; // Bereits vorhanden → kein Duplikat anlegen } ``` ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. In Outlook einen Feiertag prüfen (z.B. Ostersonntag 05.04.2026) | — | | 2. Sync abwarten | **Ostersonntag erscheint in Nexus genau einmal am 05.04.2026** | | 3. Weiteren ganztägigen Termin am selben Tag mit gleichem Betreff anlegen (zweiter Kalender) | — | | 4. Sync abwarten | **Kein zweiter Eintrag** – Duplikat wird erkannt und übersprungen | --- ## Zusatz: Serientermin-Kaskade **Problem:** Wenn ein kompletter Serienmaster in Exchange gelöscht wird, wurden die zugehörigen Instanz-Schedules in Nexus nicht mitgelöscht. Graph sendet beim Master-Delete nicht immer einzelne `@removed`-Events für alle Instanzen. ### Implementierter Fix **`StoreEvent()` – Kaskade bei Master-Delete:** ```csharp if (isRemoved && graphEvent.SeriesMasterId is null) // SeriesMasterId null = dieser Event IST der Master await this.DeleteSeriesInstancesByMasterId(graphEvent.Id, employee, cenSU); ``` **Neue Methode `DeleteSeriesInstancesByMasterId()`:** - Sucht alle `SchedulePerson`-Einträge mit `SeriesMasterId = masterEventId` für den Mitarbeiter - Lädt nur noch aktive Schedules (`IsActive = true`) - Setzt diese auf `IsActive = false` via `SaveSchedule()` (korrekte BL-Kette inkl. Exchange-Löschung) ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. In Outlook Serientermin anlegen (z.B. täglich, 7 Instanzen) | Alle 7 Instanzen erscheinen in Nexus | | 2. Gesamte Serie in Outlook löschen ("Alle Termine der Serie") | — | | 3. Sync abwarten | **Alle 7 Nexus-Instanzen werden als inaktiv markiert und verschwinden** | --- ## Zusatz: Inspector für verwaiste Serientermine **Problem:** Historisch bereits entstandene verwaiste Serientermine (vor Einführung des Kaskaden-Fix) müssen manuell bereinigt werden können. ### Implementierter Fix Neuer Inspector **"Verwaiste Serientermine (Exchange Sync)"** im c-entron WPF-Client unter `MyCentron → c-entron Inspektor`: - **Check:** Findet alle aktiven Schedules mit `SeriesMasterId`, für die kein aktiver Serienmaster-Schedule mehr in der DB existiert - **Repair:** Deaktiviert alle gefundenen verwaisten Instanzen per Knopfdruck - **Kategorie:** Zeiten (alphabetisch sortiert) ### QS-Testfall | Schritt | Erwartetes Ergebnis | |---------|-------------------| | 1. c-entron WPF öffnen → MyCentron → Inspektor | Inspector-Liste erscheint | | 2. "Verwaiste Serientermine (Exchange Sync)" auswählen und Inspektor ausführen | Check läuft durch | | 3a. Keine verwaisten Daten vorhanden | Grünes Häkchen: "Es wurden keine verwaisten Serientermine gefunden." | | 3b. Verwaiste Daten vorhanden | Rotes X mit Anzahl + **Reparieren**-Button | | 4. Reparieren klicken | Instanzen werden deaktiviert, Check läuft erneut → grünes Häkchen | --- ## Technische Grundlagen ### Sync-Architektur ``` ExchangeSyncService (HostedService, Intervall konfigurierbar, default 60s) ├── ScheduleBL.SyncByGraphV2() ← Outlook → Nexus (Graph API Delta) └── ScheduleBL.SyncOldSchedule() ← Nexus → Outlook (Schedules ohne MailEntryID) HelpdeskTimerBL / ScheduleBL ├── CreateOrUpdateTimeSchedule() ← Zeiterfassung → Outlook-Termin └── UpdateTerminplanungPersonTable() ← Termin gespeichert → Exchange aktualisieren ``` ### Schlüssel-Entitäten | Entity | Beschreibung | |--------|-------------| | `Schedule` | Termin in c-entron (DateStart, DateEnd, FullDay, Subject, IsActive, ObjectType) | | `SchedulePerson` | Verknüpfung Schedule ↔ Mitarbeiter ↔ Exchange (MailEntryID, SeriesMasterId) | | `CreatedByApp` | Herkunft: `GraphSync = 6`, `HelpdeskTime`, `GraphSyncTimeDelete` | | `CenSU` | CentronSystemUser – Systemkonto für alle Hintergrundprozesse | | `IsHelpdeskSchedule()` | Neue Hilfsmethode: prüft ob Schedule an Helpdesk/Zeit gebunden ist | ### Source of Truth | Schedule-Typ | Datum/Zeit Source of Truth | |---|---| | `HelpdeskTimerClass` / `HelpdeskClass` | **Nexus** – Exchange darf nie überschreiben | | Reine Outlook-Termine (`GraphSync`) | **Exchange/Outlook** | | Ganztages-Events (`FullDay = true`) | **Datumswert aus Graph, ohne Zeitzonenkonvertierung** | --- ## Bekannte Einschränkungen / Nicht umgesetzt | Thema | Begründung | |-------|-----------| | Kalenderfilter (welche Exchange-Kalender synchronisiert werden) | Konfigurationsthema, kein Code-Fix – muss über Exchange-Einstellungen gesteuert werden | | Blocker-Termine (`ShowAs = Free`) in Nexus anzeigen | Kein Kundenauftrag für diese Version | | ApplicationSetting für Erinnerungen (An/Aus im UI) | Für Helpdesk-Zeiten ist `isReminderOn: false` als Standard ausreichend | --- ## Offene Fragen (für Rollout-Planung) - [x] **Gibt es Kunden mit Exchange On-Premise (kein Graph API)?** → **Ja.** Diese Kunden nutzen den alten EWS-basierten Agent. Für sie greifen die Graph-spezifischen Fixes **nicht** (Delta-Token, `isCancelled`, `ParseAllDayEventDates`, Kaskaden-Delete, Duplikat-Erkennung). Separate Analyse für On-Premise-Kunden erforderlich – ggf. eigener Bugfix-Zyklus. - [x] **Welche Exchange-Kalender werden synchronisiert – nur Primärkalender?** → **Alle Kalender.** Der Endpunkt `_graphClient.Users[id].CalendarView.Delta` liefert Events aus *allen* Kalendern des Nutzers aggregiert (Primär + abonnierte Kalender wie „Feiertage in Deutschland", Teamkalender etc.). Das erklärt u.a. den Doppel-Feiertage-Bug (Ticket 156991). So gewollt – bleibt so. - [x] **Sollen Kunden nach dem Update den Inspector manuell ausführen (Kommunikation nötig)?** → **Ja.** In den Release Notes darauf hinweisen, dass Kunden mit Exchange Sync nach dem Update den Inspector „Verwaiste Serientermine (Exchange Sync)" einmalig ausführen sollen, um historisch entstandene verwaiste Serientermine zu bereinigen. - [x] **Ist der Inspector nur für Admins sichtbar (Rechteprüfung)?** → Zugriff wird über das Modul „c-entron Inspektor" gesteuert – keine separate Rechteprüfung im Inspector selbst erforderlich. ## Geltungsbereich der Fixes | Fix | Exchange Online (Graph API) | Exchange On-Premise (alter EWS-Agent) | |-----|-----------------------------|---------------------------------------| | Ticket 164020 – CenSU überschreibt Helpdesk-Zeiten | ✅ | ⚠️ Teilweise: `SyncOldSchedule()`-Filter greift, EWS-Rückweg unklar | | Ticket 163184 – Doppelter Body | ✅ | ✅ (`CreateOrUpdateTimeSchedule()` ist gemeinsamer Pfad) | | Ticket 164121 – Instanz-Delete via `isCancelled` | ✅ | ❌ EWS-Agent nutzt anderen Delete-Mechanismus | | Ticket 160145 – Ganztages +1 Tag | ✅ | ❌ Graph-spezifisch | | Ticket 158813 – Falsches Datum nach Sync | ✅ | ⚠️ Helpdesk-Schutz greift, Timezone-Fix ist Graph-spezifisch | | Ticket 157036 – Wochenanzeige Nexus | ✅ | ✅ (Frontend-Fix, kein Exchange-Bezug) | | Ticket 162826 – Erinnerungsflut | ✅ | ✅ (`AddScheduleToExchange()` gemeinsamer Pfad) | | Ticket 156991 – Doppelte Feiertage | ✅ | ❌ Graph Delta-spezifisch | | Zusatz – Serienkaskade | ✅ | ❌ Graph Delta-spezifisch | | Inspector – Cleanup verwaiste Serientermine | ✅ | ✅ (DB-Level, unabhängig vom Sync-Pfad) |