Files
motia/bitbylaw/docs/SYNC_OVERVIEW.md
bitbylaw 89fc657d47 feat(sync): Implement comprehensive sync fixes and optimizations as of February 8, 2026
- Fixed initial sync logic to respect actual timestamps, preventing unwanted overwrites.
- Introduced exponential backoff for retry logic, with auto-reset for permanently failed entities.
- Added validation checks to ensure data consistency during sync processes.
- Corrected hash calculation to only include sync-relevant communications.
- Resolved issues with empty slots ignoring user inputs and improved conflict handling.
- Enhanced handling of Var4 and Var6 entries during sync conflicts.
- Documented changes and added new fields required in EspoCRM for improved sync management.

Also added a detailed analysis of syncStatus values in EspoCRM CBeteiligte, outlining responsibilities and ensuring robust sync mechanisms.
2026-02-08 22:59:47 +00:00

1052 lines
29 KiB
Markdown

# Advoware Sync - System-Übersicht
**Letzte Aktualisierung**: 8. Februar 2026
**Status**: ✅ Production Ready
---
## Inhaltsverzeichnis
1. [System-Architektur](#system-architektur)
2. [Beteiligte Sync (Stammdaten)](#beteiligte-sync-stammdaten)
3. [Kommunikation Sync](#kommunikation-sync)
4. [Sync Status Management](#sync-status-management)
5. [Bekannte Einschränkungen](#bekannte-einschränkungen)
6. [Troubleshooting](#troubleshooting)
---
## System-Architektur
### Defense in Depth: Webhook + Cron
Das Sync-System verwendet **zwei parallele Mechanismen** für maximale Zuverlässigkeit:
```
┌──────────────────────────────────────────────────────────┐
│ EspoCRM │
│ CBeteiligte Entity (Stammdaten + Kommunikation) │
└────────────┬────────────────────────────────────┬────────┘
│ │
Webhook (Echtzeit) Cron (Fallback)
│ │
▼ ▼
┌────────────────┐ ┌────────────────┐
│ Event Handler │ │ Cron Job │
│ (Event-driven) │◄─────────────────│ (alle 15min) │
└────────┬───────┘ └────────────────┘
│ vmh.beteiligte.{create,update,delete,sync_check}
┌─────────────────────────────────────────────────────┐
│ Sync Event Handler │
│ 1. Redis Lock (verhindert Race Conditions) │
│ 2. Beteiligte Sync (Stammdaten) │
│ - rowId-basierte Change Detection │
│ - Timestamp-Vergleich für Konflikte │
│ 3. Kommunikation Sync (Phone/Email/Fax) │
│ - Hash-basierte Change Detection │
│ - Marker-Strategy für Bidirectional Matching │
│ 4. Lock Release │
└─────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────┐
│ Advoware REST API │
│ - POST/PUT/GET /Beteiligte (Stammdaten) │
│ - POST/PUT /Kommunikationen (Phone/Email/Fax) │
└─────────────────────────────────────────────────────┘
```
### Warum beide Mechanismen?
**Webhook (Primary)**:
- ✅ Echtzeit-Sync bei Änderungen
- ✅ Schnelles User-Feedback
- ❌ Kann fehlschlagen (Netzwerk, Server down, Race Conditions)
**Cron (Fallback)**:
- ✅ Garantierte Synchronisation alle 15 Minuten
- ✅ Findet "verlorene" Entities (pending_sync, dirty, failed)
- ✅ Auto-Retry für fehlgeschlagene Syncs
- ❌ Bis zu 15 Minuten Verzögerung
### Komponenten
| Komponente | Datei | Beschreibung |
|------------|-------|--------------|
| **Event Handler** | [beteiligte_sync_event_step.py](../steps/vmh/beteiligte_sync_event_step.py) | Central Sync Orchestrator |
| **Cron Job** | [beteiligte_sync_cron_step.py](../steps/vmh/beteiligte_sync_cron_step.py) | 15-Minuten Fallback |
| **Beteiligte Sync** | [beteiligte_sync_utils.py](../services/beteiligte_sync_utils.py) | Stammdaten-Sync Logik |
| **Kommunikation Sync** | [kommunikation_sync_utils.py](../services/kommunikation_sync_utils.py) | Phone/Email/Fax Sync |
| **Mapper** | [espocrm_mapper.py](../services/espocrm_mapper.py) | Entity Transformations |
| **Advoware API** | [advoware.py](../services/advoware.py) | HMAC-512 Auth REST Client |
| **EspoCRM API** | [espocrm.py](../services/espocrm.py) | X-Api-Key REST Client |
---
## Beteiligte Sync (Stammdaten)
### Scope
**Synchronisierte Felder** (8 Stammdatenfelder):
- `name` - Nachname / Firmenname (max 140 chars)
- `vorname` - Vorname bei natürlichen Personen (max 30 chars)
- `rechtsform` - Rechtsform (max 50 chars, muss in Advoware `/Rechtsformen` existieren)
- `titel` - Akademischer Titel (max 50 chars)
- `anrede` - Anrede (max 35 chars)
- `bAnrede` - Briefanrede (max 150 chars)
- `zusatz` - Zusatzinformation (max 100 chars)
- `geburtsdatum` - Geburtsdatum (datetime)
**NICHT synchronisiert**:
- ❌ Kontaktdaten (Telefon, Email, Fax) → separate Kommunikation Sync
- ❌ Adressen → separate Adressen Sync (geplant)
- ❌ Bankverbindungen → separate Endpoints (TBD)
### Change Detection: rowId
**Advoware rowId**: Base64-String, ändert sich bei **jedem** Advoware PUT:
```python
# Beispiel
GET /api/v1/advonet/Beteiligte/104860
{
"betNr": 104860,
"rowId": "FBABAAAANJFGABAAGJDOAEAPAAAAAPGFPDAFAAAA", # Base64
"name": "Mustermann",
"geaendertAm": "2026-02-08T10:30:00"
}
# Nach PUT → rowId ÄNDERT sich
PUT /api/v1/advonet/Beteiligte/104860
Response:
{
"rowId": "GBABAAAANJFGABAAGJDOAEAPAAAAAPGFPDAFAAAA", # NEU!
...
}
```
**Vergleich-Logik**:
```python
espo_rowid = espo_bet.get('advowareRowId') # Gespeichert in EspoCRM
advo_rowid = advo_bet.get('rowId') # Aktuell aus Advoware
if espo_rowid != advo_rowid:
# Advoware wurde geändert!
advo_changed = True
```
### Konflikt-Behandlung
**Erkennung**: Beide Seiten haben sich seit letztem Sync geändert
```python
# EspoCRM: Timestamp-Vergleich
espo_modified = espo_bet.get('modifiedAt')
last_sync = espo_bet.get('advowareLastSync')
espo_changed = espo_modified > last_sync
# Advoware: rowId-Vergleich
advo_changed = espo_rowid != advo_rowid
# Konflikt?
if espo_changed AND advo_changed:
conflict = True
```
**Auflösung**: EspoCRM wins IMMER!
```python
if conflict:
# 1. Stammdaten: EspoCRM → Advoware
await advoware.put_beteiligte(betnr, espo_data)
# 2. Kommunikation: NUR EspoCRM → Advoware (direction='to_advoware')
await komm_sync.sync_bidirectional(entity_id, betnr, direction='to_advoware')
# 3. Notification an User
await espocrm.create_notification(
user_id,
"Konflikt gelöst: EspoCRM-Daten wurden übernommen"
)
```
**Warum Kommunikation direction='to_advoware'?**
Bei Konflikten sollen Advoware-Änderungen NICHT zurück zu EspoCRM übertragen werden, da sonst der Konflikt wieder auftritt (Ping-Pong).
### Sync-Ablauf
#### 1. CREATE (Neu in EspoCRM)
```
User creates CBeteiligte in EspoCRM
EspoCRM Webhook → vmh.beteiligte.create
Event Handler:
1. Acquire Redis Lock (5min TTL)
2. Set syncStatus='syncing'
3. Map CBeteiligte → BeteiligterParameter
4. POST /api/v1/advonet/Beteiligte
Response: {betNr: 104860, rowId: "FBAB..."}
5. Update EspoCRM:
- betnr = 104860
- advowareRowId = "FBAB..."
- advowareLastSync = NOW
- syncStatus = 'clean'
6. Kommunikation Sync (meist leer bei CREATE)
7. Release Redis Lock
Result: Entity existiert in beiden Systemen mit betNr-Link
```
#### 2. UPDATE (Änderung in EspoCRM)
```
User updates CBeteiligte in EspoCRM
EspoCRM Webhook → vmh.beteiligte.update
Event Handler:
1. Acquire Redis Lock
2. Set syncStatus='syncing'
3. GET /api/v1/advonet/Beteiligte/{betnr}
4. Timestamp-Vergleich:
Case A: no_change (beide unverändert)
→ Skip Stammdaten, nur Kommunikation Sync
Case B: espocrm_newer (nur EspoCRM geändert)
→ PUT Advoware, Kommunikation Sync (both)
Case C: advoware_newer (nur Advoware geändert)
→ PATCH EspoCRM, Kommunikation Sync (both)
Case D: conflict (beide geändert)
→ PUT Advoware (EspoCRM wins!)
→ Kommunikation Sync (to_advoware ONLY!)
→ Notification
5. Update EspoCRM (rowId, lastSync, syncStatus)
6. Release Lock
Result: Beide Systeme synchronisiert
```
#### 3. DELETE (Gelöscht in EspoCRM)
**Problem**: Advoware DELETE ist NICHT möglich (403 Forbidden)
**Strategie**: Soft-Delete mit Notification
```
User deletes CBeteiligte in EspoCRM
EspoCRM Webhook → vmh.beteiligte.delete
Event Handler:
1. NO API Call zu Advoware (würde 403 geben)
2. Create Notification:
"Beteiligter wurde in EspoCRM gelöscht.
Bitte manuell in Advoware löschen: betNr 104860"
3. Set syncStatus='deleted_in_espocrm' (optional)
Result: Entity nur in EspoCRM gelöscht, User muss manuell in Advoware löschen
```
### Bekannte Einschränkungen
#### 1. Nur 8 von 14 Advoware-Feldern funktionieren
**Funktionierende Felder**:
✅ name, vorname, rechtsform, titel, anrede, bAnrede, zusatz, geburtsdatum
**Ignorierte Felder** (im Swagger definiert, aber PUT ignoriert sie):
❌ art, kurzname, geburtsname, familienstand, handelsRegisterNummer, registergericht
**Workaround**: Mapper verwendet nur die 8 funktionierenden Felder. Handelsregister-Daten müssen manuell in Advoware gepflegt werden.
#### 2. Advoware DELETE gibt 403
**Problem**: `/api/v1/advonet/Beteiligte/{betNr}` DELETE → 403 Forbidden
**Workaround**: Soft-Delete mit Notification, User löscht manuell in Advoware.
#### 3. rowId ändert sich bei jedem PUT
**Problem**: Auch wenn gleiche Werte geschrieben werden, ändert sich rowId
**Auswirkung**:
- Jeder Sync in Advoware invalididiert EspoCRM rowId
- Timestamp ist wichtig für echte Änderungserkennung
**Workaround**: Timestamp-Vergleich zusätzlich zu rowId-Vergleich.
#### 4. Keine Batch-Updates
**Problem**: Advoware API hat keinen Batch-Endpoint
**Auswirkung**: Bei vielen Entities viele API-Calls (N+1 Problem)
**Workaround**:
- Cron lädt max 100 Entities pro Run
- Priorisierung: pending_sync > dirty > failed > clean (>24h)
---
## Kommunikation Sync
### Scope
**Synchronisierte Kommunikationstypen** (kommKz-Enum):
| kommKz | Name | EspoCRM Type |
|--------|------|--------------|
| 1 | TelGesch | Office (Phone) |
| 2 | FaxGesch | Office (Fax) |
| 3 | Mobil | Mobile (Phone) |
| 4 | MailGesch | Office (Email) |
| 5 | Internet | Website (Email) |
| 6 | TelPrivat | Home (Phone) |
| 7 | FaxPrivat | Home (Fax) |
| 8 | MailPrivat | Home (Email) |
| 9 | AutoTelefon | Other (Phone) |
| 10 | Sonstige | Other |
| 11 | EPost | EPost (Email) |
| 12 | Bea | BeA (Email) |
### Change Detection: Hash-basiert
**Problem**: Advoware Beteiligte-rowId ändert sich NICHT bei Kommunikations-Änderungen!
```python
# Beispiel
GET /api/v1/advonet/Beteiligte/104860
{
"betNr": 104860,
"rowId": "FBAB...", # UNCHANGED!
"kommunikation": [
{"id": 88001, "rowId": "ABCD...", "tlf": "0511/12345"},
{"id": 88002, "rowId": "EFGH...", "tlf": "max@example.com"}
]
}
# User ändert Email in Advoware → max@example.com zu new@example.com
GET /api/v1/advonet/Beteiligte/104860
{
"betNr": 104860,
"rowId": "FBAB...", # STILL UNCHANGED!
"kommunikation": [
{"id": 88001, "rowId": "ABCD...", "tlf": "0511/12345"},
{"id": 88002, "rowId": "IJKL...", "tlf": "new@example.com"} # ← rowId CHANGED!
]
}
```
**Lösung**: MD5-Hash aller Kommunikation-rowIds
```python
# Hash-Berechnung
komm_rowids = sorted([k['rowId'] for k in kommunikationen])
komm_hash = hashlib.md5(''.join(komm_rowids).encode()).hexdigest()[:16]
# Speicherort in EspoCRM
cbeteiligte.kommunikationHash = "a3f5d2e8b1c4f6a9" # 16 chars
# Vergleich
if stored_hash != current_hash:
# Kommunikation hat sich geändert!
```
**Performance**: Hash nur neu berechnen wenn Advoware neu geladen wurde.
### Marker-Strategie für Bidirectional Matching
**Problem**: Wie matchen wir Einträge zwischen beiden Systemen?
```
EspoCRM:
- Email: max@example.com
- Phone: +49 511 12345
Advoware:
- tlf: "max@example.com", kommKz=4
- tlf: "+49 511 12345", kommKz=1
→ Wie wissen wir, dass EspoCRM-Email zu Advoware-ID 88002 gehört?
```
**Lösung**: Base64-Marker in Advoware `bemerkung`-Feld
```python
# Format
marker = f"[ESPOCRM:{base64_value}:{kommKz}]"
# Beispiel
bemerkung = "[ESPOCRM:bWF4QGV4YW1wbGUuY29t:4]"
# Base64 von "max@example.com", kommKz=4
# Decoding
import base64
decoded = base64.b64decode("bWF4QGV4YW1wbGUuY29t").decode()
# → "max@example.com"
```
**User-Bemerkungen werden preserviert**:
```python
bemerkung = "[ESPOCRM:...:4] Nur vormittags erreichbar"
Marker User-Text (bleibt erhalten!)
```
### 6 Sync-Varianten
Der Kommunikation-Sync behandelt **6 verschiedene Szenarien**:
| Var | Szenario | EspoCRM | Advoware | Aktion |
|-----|----------|---------|----------|--------|
| **Var1** | Neu in EspoCRM | +email | - | CREATE in Advoware (mit Marker) |
| **Var2** | Gelöscht in EspoCRM | -email | (marker) | CREATE Empty Slot |
| **Var3** | Gelöscht in Advoware | (entry) | -phone | DELETE in EspoCRM |
| **Var4** | Neu in Advoware | - | +phone | CREATE in EspoCRM (setze Marker) |
| **Var5** | Geändert in EspoCRM | email↑ | (synced) | UPDATE in Advoware |
| **Var6** | Geändert in Advoware | (synced) | phone↑ | UPDATE in EspoCRM (update Marker) |
#### Var1: Neu in EspoCRM → CREATE in Advoware
```python
# EspoCRM hat neue Email
espo_item = {
'emailAddress': 'new@example.com',
'type': 'Office'
}
# Mapper
komm_kz = 4 # MailGesch (Office Email)
base64_value = base64.b64encode(b'new@example.com').decode()
marker = f"[ESPOCRM:{base64_value}:4]"
# POST zu Advoware
POST /api/v1/advonet/Beteiligte/104860/Kommunikationen
{
"tlf": "new@example.com",
"kommKz": 4,
"bemerkung": "[ESPOCRM:bmV3QGV4YW1wbGUuY29t:4]",
"online": true
}
# Response
{
"id": 88003,
"rowId": "MNOP...",
"tlf": "new@example.com",
...
}
```
#### Var2: Gelöscht in EspoCRM → Empty Slot
**Problem**: Advoware DELETE gibt 403!
**Lösung**: "Empty Slot" - Marker bleibt, aber tlf wird leer
```python
# User löscht Email in EspoCRM
# Advoware hat noch:
{
"id": 88003,
"tlf": "old@example.com",
"bemerkung": "[ESPOCRM:b2xkQGV4YW1wbGUuY29t:4]"
}
# PUT zu Advoware
PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/88003
{
"tlf": "", # LEER!
"bemerkung": "[ESPOCRM-SLOT:4]", # Spezieller Slot-Marker
"online": false
}
# Result: Slot kann später wiederverwendet werden
```
**Slot-Reuse**: Wenn User neue Email anlegt, wird Slot wiederverwendet:
```python
# EspoCRM: User legt neue Email an
espo_item = {'emailAddress': 'reuse@example.com', 'type': 'Office'}
# Sync findet Empty Slot mit kommKz=4
slot = next(k for k in advo_komm if k['bemerkung'] == '[ESPOCRM-SLOT:4]')
# PUT statt POST
PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/{slot.id}
{
"tlf": "reuse@example.com",
"bemerkung": "[ESPOCRM:cmV1c2VAZXhhbXBsZS5jb20=:4]",
"online": true
}
```
#### Var3: Gelöscht in Advoware → DELETE in EspoCRM
```python
# Advoware: User löscht Telefonnummer
# EspoCRM hat noch:
espo_item = {
'phoneNumber': '+49 511 12345',
'type': 'Office'
}
# Sync erkennt: Marker fehlt in Advoware
# → DELETE in EspoCRM
DELETE /api/v1/espocrm/CBeteiligte/{id}/phoneNumbers/{phoneId}
```
#### Var4: Neu in Advoware → CREATE in EspoCRM
```python
# Advoware: User legt neue Telefonnummer an
advo_item = {
"id": 88004,
"tlf": "+49 30 987654",
"kommKz": 1,
"bemerkung": null # KEIN Marker!
}
# Sync erkennt: Neuer Eintrag ohne Marker
# → CREATE in EspoCRM
POST /api/v1/espocrm/CBeteiligte/{id}/phoneNumbers
{
"phoneNumber": "+49 30 987654",
"type": "Office"
}
# → UPDATE Advoware (setze Marker)
PUT /api/v1/advonet/Beteiligte/104860/Kommunikationen/88004
{
"tlf": "+49 30 987654",
"bemerkung": "[ESPOCRM:KzQ5IDMwIDk4NzY1NA==:1]"
}
```
**Initial Sync Value-Matching**: Bei erstem Sync werden identische Werte NICHT doppelt angelegt:
```python
# Initial Sync (kein kommunikationHash in EspoCRM)
espo_items = [{'emailAddress': 'max@example.com'}]
advo_items = [{'tlf': 'max@example.com', 'bemerkung': null}] # Kein Marker
# Ohne Value-Matching → 2x max@example.com!
# Mit Value-Matching → Nur Marker setzen, kein CREATE
if is_initial_sync and value in advo_values:
# Nur Marker setzen
PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/{id}
{
"tlf": "max@example.com",
"bemerkung": "[ESPOCRM:bWF4QGV4YW1wbGUuY29t:4]"
}
```
#### Var5: Geändert in EspoCRM → UPDATE in Advoware
```python
# User ändert Email in EspoCRM
old_value = "old@example.com"
new_value = "new@example.com"
# Advoware findet Entry via Marker
advo_item = find_by_marker(old_value, kommKz=4)
# UPDATE Advoware
PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/{advo_item.id}
{
"tlf": "new@example.com",
"bemerkung": "[ESPOCRM:bmV3QGV4YW1wbGUuY29t:4]", # Neuer Marker!
"online": true
}
```
#### Var6: Geändert in Advoware → UPDATE in EspoCRM
```python
# User ändert Telefonnummer in Advoware
advo_item = {
"id": 88001,
"tlf": "+49 511 999999", # GEÄNDERT!
"bemerkung": "[ESPOCRM:KzQ5IDUxMSAxMjM0NQ==:1]" # Alter Marker!
}
# Sync findet EspoCRM-Entry via Marker
espo_item = find_by_marker(decode_marker(bemerkung))
# UPDATE EspoCRM
PATCH /api/v1/espocrm/CBeteiligte/{id}/phoneNumbers/{espo_item.id}
{
"phoneNumber": "+49 511 999999"
}
# UPDATE Advoware Marker
PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/88001
{
"tlf": "+49 511 999999",
"bemerkung": "[ESPOCRM:KzQ5IDUxMSA5OTk5OTk=:1]" # Neuer Marker!
}
```
### Konflikt-Behandlung
**Bei Beteiligte-Konflikt**: Kommunikation Sync mit `direction='to_advoware'`
```python
if beteiligte_conflict:
# Kommunikation: Nur EspoCRM → Advoware
await komm_sync.sync_bidirectional(
entity_id,
betnr,
direction='to_advoware' # ← WICHTIG!
)
```
**Effekt**:
- ✅ Var1 (EspoCRM neu) → CREATE in Advoware
- ✅ Var2 (EspoCRM delete) → Empty Slot
- ✅ Var5 (EspoCRM change) → UPDATE Advoware
- ❌ Var3 (Advoware delete) → SKIP (nicht zu EspoCRM)
- ❌ Var4 (Advoware neu) → SKIP (nicht zu EspoCRM)
- ❌ Var6 (Advoware change) → REVERT (EspoCRM → Advoware)
**Var6-Revert**: Advoware-Änderungen werden rückgängig gemacht:
```python
# User änderte in Advoware: phone = "NEW"
# EspoCRM hat noch: phone = "OLD"
# Bei direction='to_advoware':
# → PUT zu Advoware mit "OLD" (Revert!)
PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/{id}
{
"tlf": "OLD", # ← EspoCRM Value!
"bemerkung": "[ESPOCRM:...:1]"
}
```
### Bekannte Einschränkungen
#### 1. Advoware kommKz ist READ-ONLY bei PUT
**Problem**: `kommKz` kann nach CREATE nicht mehr geändert werden
```python
# POST: kommKz=1 (TelGesch) - ✅ Funktioniert
POST /api/v1/advonet/Beteiligte/.../Kommunikationen
{"tlf": "...", "kommKz": 1}
# PUT: kommKz=3 (Mobil) - ❌ Wird ignoriert!
PUT /api/v1/advonet/Beteiligte/.../Kommunikationen/88001
{"tlf": "...", "kommKz": 3} # ← Ignoriert, bleibt bei 1!
```
**Workaround**: Type-Änderungen erfordern DELETE + CREATE (aber DELETE gibt 403!)
**Praktische Lösung**:
- User muss Type-Änderungen manuell in Advoware vornehmen
- Notification in EspoCRM: "Typ-Änderung nicht möglich, bitte manuell in Advoware"
#### 2. Advoware DELETE gibt 403
**Problem**: `/api/v1/advonet/Beteiligte/.../Kommunikationen/{id}` DELETE → 403
**Workaround**: Empty Slots (siehe Var2)
**Vorteil**: Slots können wiederverwendet werden, keine Duplikate
#### 3. Advoware GET kommKz=0 Bug
**Problem**: Bei GET sind alle `kommKz` Werte 0 (Advoware-Bug)
```python
# POST mit kommKz=4 ✅
Response: {"id": 88001, "kommKz": 4}
# GET ❌
Response: {"id": 88001, "kommKz": 0} # Immer 0!
```
**Workaround**: kommKz aus EspoCRM + Marker ist "Source of Truth"
```python
# Marker enthält kommKz
marker = "[ESPOCRM:...:4]"
# ↑ kommKz=4
```
#### 4. Keine Änderungs-Detection für User-Bemerkungen
**Problem**: User-Bemerkungen werden nicht synchronisiert
```
Advoware: bemerkung = "[ESPOCRM:...:4] Nur vormittags"
EspoCRM: (keine Bemerkung)
→ User-Text "Nur vormittags" bleibt nur in Advoware
```
**Grund**: Marker + User-Text sind gemischt, Parsing komplex
**Workaround**: User-Text ist Advoware-spezifisch, wird nicht synced
#### 5. Performance: Sequentielle Verarbeitung
**Problem**: Var1-6 werden sequenziell verarbeitet (N API-Calls)
**Grund**: Advoware hat keinen Batch-Endpoint
**Auswirkung**: Bei 20 Änderungen = 20 API-Calls (dauert ~10 Sekunden)
**Workaround**: Lock-TTL ist 5 Minuten, reicht für normale Anzahl
---
## Sync Status Management
### Status-Werte
| Status | Gesetzt von | Bedeutung | Cron Action |
|--------|-------------|-----------|-------------|
| `pending_sync` | EspoCRM | Neu, wartet auf ersten Sync | ✅ Sync |
| `dirty` | EspoCRM | Geändert, Sync nötig | ✅ Sync |
| `syncing` | Python | Sync läuft (Lock aktiv) | ❌ Skip |
| `clean` | Python | Synchronisiert | ✅ Sync (nach 24h) |
| `failed` | Python | Fehler, Retry möglich | ✅ Retry mit Backoff |
| `permanently_failed` | Python | Max Retries (5x) | ✅ Auto-Reset nach 24h |
| `conflict` | Python | Konflikt (optional) | ✅ Sync |
| `deleted_in_advoware` | Python | 404 von Advoware | ❌ Skip |
### Status-Transitions
```
CREATE → pending_sync
↓ Webhook/Cron
syncing
↓ Success
clean
UPDATE → dirty
↓ Webhook/Cron
syncing
↓ Success
clean
Fehler → syncing
↓ Error
failed (retry 1-4)
↓ Max Retries
permanently_failed
↓ 24h später (Cron Auto-Reset)
failed (retry 1)
Konflikt → syncing
↓ Conflict Detected
conflict
↓ Webhook/Cron
syncing (EspoCRM wins)
↓ Success
clean
```
### Retry-Strategie mit Exponential Backoff
```python
RETRY_BACKOFF_MINUTES = [1, 5, 15, 60, 240] # 1min, 5min, 15min, 1h, 4h
MAX_SYNC_RETRIES = 5
# Retry Logic
retry_count = espo_bet.get('syncRetryCount', 0)
backoff_minutes = RETRY_BACKOFF_MINUTES[retry_count]
next_retry = now + timedelta(minutes=backoff_minutes)
# Status Update
if retry_count >= MAX_SYNC_RETRIES:
status = 'permanently_failed'
auto_reset_at = now + timedelta(hours=24)
else:
status = 'failed'
next_retry_at = next_retry
```
**Cron Skip-Logik**:
```python
# Überspringe Entities wenn:
# 1. syncStatus='syncing' (Lock aktiv)
# 2. syncNextRetry > NOW (noch in Backoff-Phase)
if sync_next_retry and now < sync_next_retry:
continue # Skip this entity
```
### Lock-Management
**Redis Distributed Lock**:
```python
lock_key = f"sync_lock:cbeteiligte:{entity_id}"
acquired = redis.set(lock_key, "locked", nx=True, ex=300) # 5min TTL
```
**Lock Release mit Nested try/finally**:
```python
lock_acquired = False
try:
lock_acquired = await sync_utils.acquire_sync_lock(entity_id)
if not lock_acquired:
return # Anderer Prozess synced bereits
try:
# Sync-Logik
await sync_beteiligte(...)
await sync_kommunikation(...)
status = 'clean'
except Exception as e:
status = 'failed'
raise
finally:
# GARANTIERTE Lock-Release (auch bei Exception)
try:
await sync_utils.release_sync_lock(entity_id, status, ...)
except Exception as release_error:
# Fallback: Force Redis lock delete
await redis.delete(lock_key)
except Exception as outer_error:
# Lock wurde nicht acquired, kein Release nötig
pass
```
**Warum nested try/finally?**
Garantiert Lock-Release auch bei kritischen Fehlern (Timeout, Connection Lost, Memory Error).
---
## Bekannte Einschränkungen
### Advoware API Limits
1. **DELETE gibt 403** für Beteiligte und Kommunikationen
- Workaround: Soft-Delete mit Notification (Beteiligte) oder Empty Slots (Kommunikation)
2. **Nur 8 von 14 Beteiligte-Felder funktionieren**
- Ignoriert: art, kurzname, geburtsname, familienstand, handelsRegisterNummer, registergericht
- Workaround: Manuelle Pflege dieser Felder in Advoware
3. **kommKz ist READ-ONLY bei PUT**
- Typ-Änderungen (z.B. TelGesch → Mobil) nicht möglich
- Workaround: Notification, User ändert manuell
4. **kommKz=0 bei GET** (Advoware-Bug)
- Workaround: Marker enthält korrekten kommKz-Wert
5. **Keine Batch-Endpoints**
- N+1 Problem bei vielen Änderungen
- Workaround: Sequentielle Verarbeitung mit Lock-TTL 5min
### System-Limits
1. **Max 100 Entities pro Cron-Run**
- Grund: Timeout-Vermeidung
- Workaround: Priorisierung (pending_sync > dirty > failed)
2. **Lock-TTL 5 Minuten**
- Bei sehr vielen Kommunikations-Änderungen evtl. zu kurz
- Workaround: Bei >50 Kommunikationen wird Lock erneuert
3. **Keine Transaktionen**
- Partial Updates möglich bei Fehlern
- Workaround: Hash wird nur bei vollständigem Erfolg updated
4. **Webhook-Race-Conditions**
- Mehrere schnelle Updates können Race Conditions erzeugen
- Workaround: Redis Lock + Cron-Fallback
---
## Troubleshooting
### Symptom: Entity bleibt bei 'syncing'
**Ursache**: Lock wurde nicht released (Server-Crash, Timeout)
**Lösung**:
```bash
# Redis Lock manuell löschen
redis-cli DEL sync_lock:cbeteiligte:68e4aef68d2b4fb98
# Entity Status zurücksetzen
# In EspoCRM Admin:
syncStatus = 'dirty'
```
### Symptom: Entity bei 'permanently_failed'
**Ursache**: 5 Sync-Versuche fehlgeschlagen
**Analyse**:
```python
# Check syncErrorMessage in EspoCRM
entity = await espocrm.get_entity('CBeteiligte', entity_id)
print(entity['syncErrorMessage'])
# z.B. "404 Not Found: betNr 104860"
```
**Lösungen**:
- **404**: betNr existiert nicht in Advoware → betNr in EspoCRM korrigieren
- **400**: Validation-Error → Daten prüfen (z.B. rechtsform nicht in Liste)
- **401**: Auth-Error → Advoware Token prüfen
- **503**: Advoware down → Warten auf Auto-Reset (24h)
**Manueller Reset**:
```python
# In EspoCRM Admin:
syncStatus = 'dirty'
syncRetryCount = 0
syncNextRetry = null
```
### Symptom: Duplikate in Kommunikation
**Ursache**: Initial Sync ohne Value-Matching (sollte gefixt sein)
**Analyse**:
```python
# Check kommunikationHash
entity = await espocrm.get_entity('CBeteiligte', entity_id)
print(entity['kommunikationHash']) # null = Initial Sync lief nicht
# Check Marker in Advoware
advo_bet = await advoware.get_beteiligter(betnr)
for komm in advo_bet['kommunikation']:
print(komm['bemerkung']) # Sollte [ESPOCRM:...:X] enthalten
```
**Lösung**:
```python
# Manuell Duplikate entfernen (via EspoCRM)
# Dann syncStatus='dirty' → Re-Sync setzt Marker
```
### Symptom: Hash mismatch nach Sync
**Ursache**: Partial Update (einige Var fehlgeschlagen)
**Analyse**:
```python
# Check syncErrorMessage
entity = await espocrm.get_entity('CBeteiligte', entity_id)
print(entity['syncErrorMessage'])
# z.B. "Var1: 2 succeeded, Var4: 1 failed (403)"
```
**Lösung**:
- Hash wird NUR bei vollständigem Erfolg updated
- Bei partial failure: syncStatus='failed', Hash bleibt alt
- Retry wird Diff korrekt berechnen
### Symptom: Konflikt-Loop
**Ursache**: User ändert in Advoware während Sync läuft
**Lösung**: Lock sollte dies verhindern, aber bei Race Condition:
```python
# Check syncStatus History (in Logs)
# Wenn Konflikt > 3x in 15min → Manuelle Intervention
# Temporär Webhook disable für diese Entity
# In EspoCRM Admin:
syncStatus = 'clean'
# User kontaktieren, Änderungen koordinieren
```
---
## Best Practices
### 1. Monitoring
**Metriken**:
- `sync_duration_seconds` - Sync-Dauer pro Entity
- `sync_errors_total` - Anzahl Fehler pro Error-Type
- `sync_retries_total` - Retry-Count pro Entity
- `sync_conflicts_total` - Konflikt-Rate
**Alerts**:
- permanently_failed > 10 Entities
- sync_duration > 60 Sekunden
- sync_errors > 50/hour
### 2. Daten-Qualität
**Vor Sync prüfen**:
- ✅ rechtsform existiert in Advoware `/Rechtsformen`
- ✅ Email-Format valide
- ✅ Telefonnummer Format valide (E.164 empfohlen)
**Validation**:
```python
# In Event Handler vor Sync
validation_errors = await sync_utils.validate_entity(espo_entity)
if validation_errors:
await sync_utils.release_sync_lock(
entity_id,
'failed',
f"Validation failed: {validation_errors}"
)
return
```
### 3. Testing
**Test-Entities**:
- Dedizierte Test-betNr in Advoware (z.B. 999xxx)
- Test-Entities in EspoCRM mit Präfix "TEST-"
- Separate Service-Account für Tests
**Integration-Tests**:
```bash
# Scenario: Create-Update-Conflict
pytest tests/integration/test_beteiligte_sync.py::test_full_lifecycle
```
### 4. Rollback bei Problemen
**Webhook temporär disablen**:
```bash
# In EspoCRM Webhook-Settings:
# vmh.beteiligte.* → Status: Inactive
```
**Cron temporär stoppen**:
```bash
# In Kubernetes/systemd:
kubectl scale deployment motia-cron --replicas=0
```
**Entities zurücksetzen**:
```sql
-- In EspoCRM Database (PostgreSQL)
UPDATE c_beteiligte
SET sync_status = 'pending_sync',
sync_retry_count = 0
WHERE sync_status = 'permanently_failed';
```
---
## Weiterführende Links
- [Beteiligte Sync Details](BETEILIGTE_SYNC.md)
- [Kommunikation Sync Details](KOMMUNIKATION_SYNC.md)
- [Field Mapping Referenz](ADVOWARE_BETEILIGTE_FIELDS.md)
- [Advoware API Swagger](advoware/advoware_api_swagger.json)
- [Troubleshooting Guide](TROUBLESHOOTING.md)
- [Archiv: Historische Analysen](archive/)