diff --git a/bitbylaw/steps/advoware_cal_sync/README.md b/bitbylaw/steps/advoware_cal_sync/README.md index 0943c687..67d14b25 100644 --- a/bitbylaw/steps/advoware_cal_sync/README.md +++ b/bitbylaw/steps/advoware_cal_sync/README.md @@ -288,3 +288,42 @@ Verwende `BERLIN_TZ.localize(naive_datetime)` statt `.replace(tzinfo=BERLIN_TZ)` Der Sync funktioniert jetzt perfekt für alle Mitarbeiter ohne Limit auf 'AI'. Update-Loops wurden durch korrekte `last_sync`-Setzung auf die Zeit nach dem Update behoben. +## Kritischer Bugfix: Enddatum bei wiederholenden Terminen + +### Problemstellung +Bei wiederholenden Terminen (`dauertermin=1`) wurde fälschlicherweise `datumBis` als Enddatum für die Event-Dauer verwendet. `datumBis` ist jedoch das **Ende der Wiederholungsserie**, nicht das Ende des einzelnen Termins! + +**Beispiel (frNr 85909):** +- `datum`: "2025-10-24T06:00:00" (Termin-Start) +- `datumBis`: "2025-11-24T00:00:00" (Serie-Ende: 24.11.2025) +- `uhrzeitBis`: "06:30:00" (Termin-Ende) + +**Falsche Berechnung:** +- Enddatum = `datumBis` = 2025-11-24 +- Event-Ende = 2025-11-24T06:30:00 (Monat später!) +- Nach Vorbereitungs-/Fahrtzeiten: Dauer >30 Tage +- Google Calendar Limit: Gekappt auf 24h → Event von 03:40 bis 03:40+24h + +### Lösung +Bei wiederholenden Terminen (`dauertermin=1`) muss das Enddatum aus dem **gleichen Tag** wie `datum` kommen: + +```python +# KORREKT: Immer datum als Basis für Enddatum verwenden +end_date_str = data.get('datum', '') # Nicht datumBis! +``` + +**Richtige Berechnung:** +- Enddatum = `datum` = 2025-10-24 +- Event-Ende = 2025-10-24T06:30:00 +- Nach Vorbereitungs-/Fahrtzeiten: 03:40 - 08:20 (4:40h) ✅ + +### Implementierung +- In `standardize_appointment_data()`: `end_date_str = data.get('datum', '')` +- `datumBis` wird nur noch für RRULE-Generierung verwendet +- Bei dauertermin=0 und dauertermin=1 gleiche Logik + +### Auswirkung +- Events haben jetzt korrekte Dauer (keine 24h-Kappung bei kurzen Terminen) +- Zeitaufteilung in Beschreibungen ist präzise +- Google Calendar zeigt Events mit realistischen Zeiträumen an + diff --git a/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py b/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py index a8c1476a..43419072 100644 --- a/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py +++ b/bitbylaw/steps/advoware_cal_sync/calendar_sync_event_step.py @@ -187,6 +187,7 @@ def generate_rrule(turnus, turnus_art, datum_bis): def standardize_appointment_data(data, source): """Standardize data from Advoware or Google to comparable dict, with TZ handling.""" + duration_capped = False # Initialize flag for duration capping if source == 'advoware': start_str = data.get('datum', '') # Improved parsing: if datum contains 'T', it's datetime; else combine with uhrzeitVon @@ -200,8 +201,9 @@ def standardize_appointment_data(data, source): start_time = data.get('uhrzeitVon') or '09:00:00' start_dt = BERLIN_TZ.localize(datetime.datetime.fromisoformat(f"{start_str}T{start_time}")) - # For end: Use date from datumBis (or datum), time from uhrzeitBis - end_date_str = data.get('datumBis', data.get('datum', '')) + # For end: Use date from datum (not datumBis for recurring events!), time from uhrzeitBis + # datumBis is only for recurrence end date, not individual event end date + end_date_str = data.get('datum', '') if 'T' in end_date_str: base_end_date = end_date_str.split('T')[0] else: @@ -216,12 +218,92 @@ def standardize_appointment_data(data, source): # Anonymization for Google events if Config.CALENDAR_SYNC_ANONYMIZE_GOOGLE_EVENTS: text = 'Advoware blocked' - notiz = '' ort = '' + original_notiz = '' # Bei Anonymisierung keine Original-Notiz anzeigen else: text = data.get('text', '') - notiz = data.get('notiz', '') ort = data.get('ort', '') + original_notiz = data.get('notiz', '') + + # Process preparation time and travel time (immer, auch bei Anonymisierung) + vorbereitungs_dauer = data.get('vorbereitungsDauer', '00:00:00') + fahrzeit = data.get('fahrzeit', '00:00:00') + fahrt_anzeigen = data.get('fahrtAnzeigen', 0) + + # Parse times (format: HH:MM:SS) + try: + vorb_h, vorb_m, vorb_s = map(int, vorbereitungs_dauer.split(':')) + vorbereitung_td = timedelta(hours=vorb_h, minutes=vorb_m, seconds=vorb_s) + except: + vorbereitung_td = timedelta(0) + + try: + fahrt_h, fahrt_m, fahrt_s = map(int, fahrzeit.split(':')) + fahrt_td = timedelta(hours=fahrt_h, minutes=fahrt_m, seconds=fahrt_s) + except: + fahrt_td = timedelta(0) + + # Calculate travel times based on fahrtAnzeigen + hinfahrt_td = timedelta(0) + rueckfahrt_td = timedelta(0) + + if fahrt_anzeigen == 1: # Nur Hinfahrt + hinfahrt_td = fahrt_td + elif fahrt_anzeigen == 2: # Nur Rückfahrt + rueckfahrt_td = fahrt_td + elif fahrt_anzeigen == 3: # Beides (volle Fahrtzeit für Hin- und Rückfahrt) + hinfahrt_td = fahrt_td + rueckfahrt_td = fahrt_td + + # Adjust start and end times + adjusted_start = start_dt - vorbereitung_td - hinfahrt_td + adjusted_end = end_dt + rueckfahrt_td + + # Create detailed description with time breakdown + time_breakdown = [] + + if vorbereitung_td.total_seconds() > 0: + vorb_start = adjusted_start + vorb_end = adjusted_start + vorbereitung_td + time_breakdown.append(f"{vorb_start.strftime('%H:%M')}-{vorb_end.strftime('%H:%M')} Vorbereitung") + + if hinfahrt_td.total_seconds() > 0: + outbound_start = adjusted_start + vorbereitung_td + outbound_end = adjusted_start + vorbereitung_td + hinfahrt_td + time_breakdown.append(f"{outbound_start.strftime('%H:%M')}-{outbound_end.strftime('%H:%M')} Hinfahrt") + + # Actual appointment time + appt_start = adjusted_start + vorbereitung_td + hinfahrt_td + appt_end = adjusted_end - rueckfahrt_td + time_breakdown.append(f"{appt_start.strftime('%H:%M')}-{appt_end.strftime('%H:%M')} Termin") + + if rueckfahrt_td.total_seconds() > 0: + return_start = appt_end + return_end = adjusted_end + time_breakdown.append(f"{return_start.strftime('%H:%M')}-{return_end.strftime('%H:%M')} Rückfahrt") + + # Combine description + notiz_parts = [] + if original_notiz.strip(): + notiz_parts.append(original_notiz.strip()) + notiz_parts.append("Zeitaufteilung:") + notiz_parts.extend(time_breakdown) + if duration_capped: + notiz_parts.append("\nHinweis: Ereignisdauer wurde auf 24 Stunden begrenzt (Google Calendar Limit)") + notiz = "\n".join(notiz_parts) + + # Update start and end times + start_dt = adjusted_start + end_dt = adjusted_end + + # Check for Google Calendar duration limit (24 hours max) + duration = end_dt - start_dt + max_duration = timedelta(hours=24) + duration_capped = False + if duration > max_duration: + logger.warning(f"Event duration {duration} exceeds Google Calendar limit of {max_duration}. Capping at 24 hours.") + end_dt = start_dt + max_duration + duration_capped = True # Generate recurrence if dauertermin recurrence = None @@ -493,9 +575,9 @@ async def handler(event, context): logger.warning(f"Mitarbeiter ohne Kürzel übersprungen: {employee}") continue - # DEBUG: Nur für Nutzer RO syncen - if kuerzel != 'ST': - logger.info(f"DEBUG: Überspringe {kuerzel}, nur RO wird gesynct") + # DEBUG: Nur für Nutzer AI syncen (für Test der Travel/Prep Zeit) + if kuerzel != 'AI': + logger.info(f"DEBUG: Überspringe {kuerzel}, nur AI wird gesynct") continue logger.info(f"Starting calendar sync for {kuerzel}")