# Beteiligte Sync - Bidirektionale Synchronisation EspoCRM ↔ Advoware

## Übersicht

Bidirektionale Synchronisation der **Stammdaten** von Beteiligten zwischen EspoCRM (CBeteiligte) und Advoware (Beteiligte).

**Scope**: Nur Stammdaten (Name, Rechtsform, Geburtsdatum, Anrede, Handelsregister)  
**Out of Scope**: Kontaktdaten (Telefon, Email, Fax, Bankverbindungen) → separate Endpoints

## Architektur

### Event-Driven Architecture

```
┌─────────────┐
│  EspoCRM    │ Webhook → vmh.beteiligte.{create,update,delete}
│ CBeteiligte │              ↓
└─────────────┘         ┌────────────────────┐
                        │  Event Handler     │
┌─────────────┐         │  (sync_event_step) │
│   Cron      │ ───→    │                    │
│  (15 min)   │ sync_   │  - Lock (Redis)    │
└─────────────┘ check   │  - Timestamp Check │
                        │  - Merge & Sync    │
                        └────────┬───────────┘
                                 ↓
                        ┌────────────────────┐
                        │   Advoware API     │
                        │   /Beteiligte      │
                        └────────────────────┘
```

### Komponenten

1. **Event Handler** ([beteiligte_sync_event_step.py](../steps/vmh/beteiligte_sync_event_step.py))
   - Subscribes: `vmh.beteiligte.{create,update,delete,sync_check}`
   - Verarbeitet Sync-Events
   - Verwendet Redis distributed lock

2. **Cron Job** ([beteiligte_sync_cron_step.py](../steps/vmh/beteiligte_sync_cron_step.py))
   - Läuft alle 15 Minuten
   - Findet Entities mit Sync-Bedarf
   - Emittiert `sync_check` Events

3. **Sync Utils** ([beteiligte_sync_utils.py](../services/beteiligte_sync_utils.py))
   - Lock-Management (Redis distributed lock)
   - Timestamp-Vergleich
   - Merge-Utility für Advoware PUT
   - Notifications

4. **Mapper** ([espocrm_mapper.py](../services/espocrm_mapper.py))
   - `map_cbeteiligte_to_advoware()` - EspoCRM → Advoware
   - `map_advoware_to_cbeteiligte()` - Advoware → EspoCRM
   - Nur Stammdaten, keine Kontaktdaten

5. **APIs**
   - [espocrm.py](../services/espocrm.py) - EspoCRM API Client
   - [advoware.py](../services/advoware.py) - Advoware API Client

## Sync-Strategie

### State Management
- **Sync-Status in EspoCRM** (nicht PostgreSQL)
- **Field**: `syncStatus` (enum mit 7 Werten)
- **Lock**: Redis distributed lock (5 min TTL)

### Konfliktauflösung
- **Policy**: EspoCRM wins
- **Detection**: Timestamp-Vergleich (`modifiedAt` vs `geaendertAm`)
- **Notification**: In-App Notification in EspoCRM

### Sync-Status Values

```typescript
enum SyncStatus {
  clean              // ✅ Synced, keine Änderungen
  dirty              // 📝 Lokale Änderungen, noch nicht synced
  pending_sync       // ⏳ Wartet auf ersten Sync
  syncing            // 🔄 Sync läuft gerade (Lock)
  failed             // ❌ Sync fehlgeschlagen (retry möglich)
  conflict           // ⚠️  Konflikt erkannt
  permanently_failed // 💀 Max retries erreicht (5x)
}
```

## Datenfluss

### 1. Create (Neu in EspoCRM)
```
EspoCRM (neu) → Webhook → Event Handler
  ↓
Acquire Lock (Redis)
  ↓
Map EspoCRM → Advoware
  ↓
POST /api/v1/advonet/Beteiligte
  ↓
Response: {betNr: 12345}
  ↓
Update EspoCRM: betnr=12345, syncStatus=clean
  ↓
Release Lock
```

### 2. Update (Änderung in EspoCRM)
```
EspoCRM (geändert) → Webhook → Event Handler
  ↓
Acquire Lock (Redis)
  ↓
GET /api/v1/advonet/Beteiligte/{betnr}
  ↓
Timestamp-Vergleich (rowId + modifiedAt vs geaendertAm):
  - no_change → Nur Kommunikation sync (direction=both)
  - espocrm_newer → Update Advoware (PUT) + Kommunikation sync (direction=both)
  - advoware_newer → Update EspoCRM (PATCH) + Kommunikation sync (direction=both)
  - conflict → EspoCRM wins (PUT) + Notification + Kommunikation sync (direction=to_advoware ONLY!)
  ↓
Kommunikation Sync (Hash-basiert, siehe unten)
  ↓
Release Lock (NACH Kommunikation-Sync!)
```

### 3. Cron Check
```
Cron (alle 15 min)
  ↓
Query EspoCRM:
  - syncStatus IN (pending_sync, dirty, failed)
  - OR (clean AND advowareLastSync > 24h)
  ↓
Batch emit: vmh.beteiligte.sync_check events
  ↓
Event Handler (siehe Update)
```

## Optimierungen

### 1. Redis Distributed Lock (Atomicity)
```python
lock_key = f"sync_lock:cbeteiligte:{entity_id}"
acquired = redis.set(lock_key, "locked", nx=True, ex=300)
```
- ✅ Verhindert Race Conditions
- ✅ TTL verhindert Deadlocks (5 min)

### 2. Combined API Calls (Performance)
```python
await sync_utils.release_sync_lock(
    entity_id,
    'clean',
    extra_fields={'betnr': new_betnr}  # ← kombiniert 2 calls in 1
)
```
- ✅ 33% weniger API Requests

### 3. Merge Utility (Code Quality)
```python
merged = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
```
- ✅ Keine Code-Duplikation
- ✅ Konsistentes Logging
- ✅ Wiederverwendbar

### 4. Max Retry Limit (Robustheit)
```python
MAX_SYNC_RETRIES = 5

if retry_count >= 5:
    status = 'permanently_failed'
    send_notification("Max retries erreicht")
```
- ✅ Verhindert infinite loops
- ✅ User wird benachrichtigt

### 5. Batch Processing (Scalability)
```python
tasks = [context.emit(...) for entity_id in entity_ids]
results = await asyncio.gather(*tasks, return_exceptions=True)
```
- ✅ 90% schneller bei 100 Entities

## Kommunikation-Sync Integration

**WICHTIG**: Kommunikation-Sync läuft **IMMER** nach Stammdaten-Sync (auch bei `no_change`)!

### Hash-basierte Änderungserkennung ✅

Die Kommunikation-Synchronisation verwendet **MD5-Hash** der `kommunikation` rowIds aus Advoware:
- **Hash-Berechnung**: MD5 von sortierten rowIds (erste 16 Zeichen)
- **Speicherort**: `kommunikationHash` in EspoCRM CBeteiligte
- **Vorteil**: Erkennt Kommunikations-Änderungen ohne Beteiligte-rowId-Änderung

**Problem gelöst**: Beteiligte-rowId ändert sich **NICHT**, wenn nur Kommunikation geändert wird!

### 3-Way Diffing mit Konflikt-Erkennung

```python
# Timestamp-basiert für EspoCRM
espo_changed = espo_bet.modifiedAt > espo_bet.advowareLastSync

# Hash-basiert für Advoware  
stored_hash = espo_bet.kommunikationHash  # z.B. "a3f5d2e8b1c4f6a9"
current_hash = MD5(sorted(komm.rowId for komm in advo_kommunikationen))[:16]
advo_changed = stored_hash != current_hash

# Konflikt-Erkennung
if espo_changed AND advo_changed:
    espo_wins = True  # EspoCRM gewinnt immer!
```

### Konflikt-Behandlung: EspoCRM Wins

**Bei Konflikt** (beide Seiten geändert):
1. **Stammdaten**: EspoCRM → Advoware (PUT)
2. **Kommunikation**: `direction='to_advoware'` (NUR EspoCRM→Advoware, blockiert Advoware→EspoCRM)
3. **Notification**: In-App Benachrichtigung
4. **Hash-Update**: Neuer Hash wird gespeichert

**Ohne Konflikt**:
- **Stammdaten**: Je nach Timestamp-Vergleich
- **Kommunikation**: `direction='both'` (bidirektional)

### 6 Sync-Varianten (Var1-6)

**Var1**: Neu in EspoCRM → CREATE in Advoware  
**Var2**: Gelöscht in EspoCRM → DELETE in Advoware (Empty Slot)  
**Var3**: Gelöscht in Advoware → DELETE in EspoCRM  
**Var4**: Neu in Advoware → CREATE in EspoCRM  
**Var5**: Geändert in EspoCRM → UPDATE in Advoware  
**Var6**: Geändert in Advoware → UPDATE in EspoCRM

### Base64-Marker Strategie
```
[ESPOCRM:bWF4QGV4YW1wbGUuY29t:4] Geschäftlich
[ESPOCRM-SLOT:4]  # Leerer Slot nach Löschung
```

### Base64-Marker Strategie

**Marker-Format** im Advoware `bemerkung` Feld:
```
[ESPOCRM:bWF4QGV4YW1wbGUuY29t:4] Geschäftlich
[ESPOCRM-SLOT:4]  # Leerer Slot nach Löschung
```

**Base64-Encoding statt Hash**:
- **Vorteil**: Bidirektional! Marker enthält den **tatsächlichen Wert** (Base64-kodiert)
- **Matching**: Selbst wenn Wert in Advoware ändert, kann alter Wert aus Marker dekodiert werden
- **Beispiel**: 
  ```python
  # Advoware: old@example.com → new@example.com
  # Alter Marker: [ESPOCRM:b2xkQGV4YW1wbGUuY29t:4]
  # Sync dekodiert: "old@example.com" → Findet Match in EspoCRM ✅
  # Update: EspoCRM-Eintrag + Marker mit neuem Base64-Wert
  ```

### 4-Stufen kommKz-Erkennung (Type Detection)

**Problem**: Advoware `kommKz` ist via GET immer 0, via PUT read-only!

**Lösung - Prioritäts-Kaskade**:
1. **Marker** (höchste Priorität) → `[ESPOCRM:...:3]` = kommKz 3 (Mobil)
2. **EspoCRM Type** (bei EspoCRM→Advoware) → `type: 'Mobile'` = kommKz 3
3. **Top-Level Felder** → `beteiligte.mobil` = kommKz 3
4. **Wert-Pattern** → `@` in Wert = Email (kommKz 4)
5. **Default** → Fallback (TelGesch=1, MailGesch=4)

**Mapping EspoCRM phoneNumberData.type → kommKz**:
```python
PHONE_TYPE_TO_KOMMKZ = {
    'Office': 1,   # TelGesch
    'Fax': 2,      # FaxGesch
    'Mobile': 3,   # Mobil
    'Home': 6,     # TelPrivat
    'Other': 10    # Sonstige
}
```

### Slot-Wiederverwendung (Empty Slots)

**Problem**: Advoware DELETE gibt 403 Forbidden!

**Lösung**: Empty Slots mit Marker
```python
# Gelöscht in EspoCRM → Create Empty Slot in Advoware
{
    "tlf": "",
    "bemerkung": "[ESPOCRM-SLOT:4]",  # kommKz=4 (Email)
    "kommKz": 4,
    "online": True
}
```

**Wiederverwendung**:
- Neue Einträge prüfen zuerst Empty Slots mit passendem kommKz
- UPDATE statt CREATE spart API-Calls und IDs

### Lock-Management mit Redis

**WICHTIG**: Lock wird erst NACH Kommunikation-Sync freigegeben!

```python
# Pattern in allen 4 Szenarien:
await sync_utils.acquire_sync_lock(entity_id)
try:
    # 1. Stammdaten sync
    # 2. Kommunikation sync (run_kommunikation_sync helper)
    # 3. Lock release
    await sync_utils.release_sync_lock(entity_id, 'clean')
finally:
    # Failsafe: Lock wird auch bei Exception released
    pass
```

**Vorher (BUG)**: Lock wurde teilweise VOR Kommunikation-Sync released!  
**Jetzt**: Konsistentes Pattern - Lock schützt gesamte Operation

### Implementation Details

**Implementation**:
- [kommunikation_mapper.py](../services/kommunikation_mapper.py) - Base64 encoding/decoding, kommKz detection
- [kommunikation_sync_utils.py](../services/kommunikation_sync_utils.py) - Sync-Manager mit 3-way diffing
- [beteiligte_sync_event_step.py](../steps/vmh/beteiligte_sync_event_step.py) - Event handler mit helper function
- Tests: [test_kommunikation_sync_implementation.py](../scripts/test_kommunikation_sync_implementation.py)

**Helper Function** (DRY-Prinzip):
```python
async def run_kommunikation_sync(entity_id, betnr, komm_sync, context, direction='both'):
    """Führt Kommunikation-Sync aus mit Error-Handling und Logging"""
    context.logger.info(f"📞 Starte Kommunikation-Sync (direction={direction})...")
    komm_result = await komm_sync.sync_bidirectional(entity_id, betnr, direction=direction)
    return komm_result
```

**Verwendet in**:
- no_change: `direction='both'`
- espocrm_newer: `direction='both'`
- advoware_newer: `direction='both'`
- **conflict**: `direction='to_advoware'` ← NUR EspoCRM→Advoware!

## Performance

| Operation | API Calls | Latency |
|-----------|-----------|---------|
| CREATE | 2 | ~200ms |
| UPDATE (initial) | 2 | ~250ms |
| UPDATE (normal) | 2 | ~250ms |
| Cron (100 entities) | 200 | ~1s (parallel) |

## Monitoring

### Sync-Status Tracking
```sql
-- In EspoCRM
SELECT syncStatus, COUNT(*) 
FROM c_beteiligte 
GROUP BY syncStatus;
```

### Failed Syncs
```sql
-- Entities mit Sync-Problemen
SELECT id, name, syncStatus, syncErrorMessage, syncRetryCount
FROM c_beteiligte 
WHERE syncStatus IN ('failed', 'permanently_failed')
ORDER BY syncRetryCount DESC;
```

## Fehlerbehandlung

### Retriable Errors
- Netzwerk-Timeout
- 500 Internal Server Error
- 503 Service Unavailable

→ Status: `failed`, retry beim nächsten Cron

### Non-Retriable Errors
- 400 Bad Request (invalid data)
- 404 Not Found (entity deleted)
- 401 Unauthorized (auth error)

→ Status: `failed`, keine automatischen Retries

### Max Retries Exceeded
- Nach 5 Versuchen: `permanently_failed`
- User erhält In-App Notification
- Manuelle Prüfung erforderlich

## Testing

### Unit Tests
```bash
cd /opt/motia-app/bitbylaw
source python_modules/bin/activate
python scripts/test_beteiligte_sync.py
```

### Manual Test
```python
# Test single entity sync
event_data = {
    'entity_id': '68e3e7eab49f09adb',
    'action': 'sync_check',
    'source': 'manual_test'
}
await beteiligte_sync_event_step.handler(event_data, context)
```

## Entity Mapping

### EspoCRM CBeteiligte → Advoware Beteiligte

| EspoCRM Field | Advoware Field | Type | Notes |
|---------------|----------------|------|-------|
| `lastName` | `name` | string | Bei Person |
| `firstName` | `vorname` | string | Bei Person |
| `firmenname` | `name` | string | Bei Firma |
| `rechtsform` | `rechtsform` | string | Person/Firma |
| `salutationName` | `anrede` | string | Herr/Frau |
| `dateOfBirth` | `geburtsdatum` | date | Nur Person |
| `handelsregisterNummer` | `handelsRegisterNummer` | string | Nur Firma |
| `betnr` | `betNr` | int | Foreign Key |

**Nicht gemapped**: Telefon, Email, Fax, Bankverbindungen (→ separate Endpoints)

## Troubleshooting

### Sync bleibt bei "syncing" hängen
**Problem**: Redis lock expired, aber syncStatus nicht zurückgesetzt  
**Lösung**: 
```python
# Lock ist automatisch nach 5 min weg (TTL)
# Manuelles zurücksetzen:
await espocrm.update_entity('CBeteiligte', entity_id, {'syncStatus': 'dirty'})
```

### "Max retries exceeded"
**Problem**: Entity ist `permanently_failed`  
**Lösung**:
1. Prüfe `syncErrorMessage` für Details
2. Behebe das Problem (z.B. invalide Daten)
3. Reset: `syncStatus='dirty', syncRetryCount=0`

### Race Condition / Parallele Syncs
**Problem**: Zwei Syncs gleichzeitig (sollte nicht passieren)  
**Lösung**: Redis lock verhindert das automatisch

## Configuration

### Environment Variables
```bash
# EspoCRM
ESPOCRM_API_BASE_URL=https://crm.bitbylaw.com/api/v1
ESPOCRM_MARVIN_API_KEY=e53def10eea27b92a6cd00f40a3e09a4

# Advoware
ADVOWARE_API_BASE_URL=https://www2.advo-net.net:90/
ADVOWARE_PRODUCT_ID=...
ADVOWARE_APP_ID=...
ADVOWARE_API_KEY=...

# Redis
REDIS_HOST=localhost
REDIS_PORT=6379
REDIS_DB_ADVOWARE_CACHE=1
```

### EspoCRM Entity Fields
Custom fields für Sync-Management:
- `betnr` (int, unique) - Foreign Key zu Advoware
- `syncStatus` (enum) - Sync-Status
- `advowareLastSync` (datetime) - Letzter erfolgreicher Sync
- `advowareDeletedAt` (datetime) - Soft-Delete timestamp
- `advowareRowId` (varchar, 50) - Cached Advoware rowId für Change Detection
- **`kommunikationHash` (varchar, 16)** - MD5-Hash der Kommunikation rowIds (erste 16 Zeichen)
- `syncErrorMessage` (text, 2000 chars) - Letzte Fehlermeldung
- `syncRetryCount` (int) - Anzahl fehlgeschlagener Versuche

## Deployment

### 1. Deploy Code
```bash
cd /opt/motia-app/bitbylaw
git pull
source python_modules/bin/activate
pip install -r requirements.txt
```

### 2. Restart Motia
```bash
# Motia Workbench restart (lädt neue Steps)
systemctl restart motia-workbench  # oder entsprechender Befehl
```

### 3. Verify
```bash
# Check logs
tail -f /var/log/motia/workbench.log

# Test single sync
python scripts/test_beteiligte_sync.py
```

## Weitere Advoware-Syncs

Dieses System ist als **Template für alle Advoware-Syncs** designed. Wichtige Prinzipien:

1. **Redis Distributed Lock** für atomare Operations
2. **Merge Utility** für Read-Modify-Write Pattern
3. **Max Retries** mit Notification
4. **Batch Processing** in Cron
5. **Combined API Calls** wo möglich

→ Siehe [SYNC_TEMPLATE.md](SYNC_TEMPLATE.md) für Implementierungs-Template

## Siehe auch

- [Entity Mapping Details](../ENTITY_MAPPING_CBeteiligte_Advoware.md)
- [Advoware API Docs](advoware/)
- [EspoCRM API Docs](API.md)
