634 lines
22 KiB
Markdown
634 lines
22 KiB
Markdown
# Advoware Sync Template
|
|
|
|
Template für neue bidirektionale Syncs zwischen EspoCRM und Advoware.
|
|
|
|
## Quick Start
|
|
|
|
Für neuen Sync von Entity `XYZ`:
|
|
|
|
### 1. EspoCRM Custom Fields
|
|
```sql
|
|
-- In EspoCRM Admin → Entity Manager → XYZ
|
|
advowareId (int, unique) -- Foreign Key zu Advoware
|
|
advowareRowId (varchar 50) -- Für Change Detection (WICHTIG!)
|
|
syncStatus (enum: clean|dirty|...) -- Status tracking
|
|
advowareLastSync (datetime) -- Timestamp letzter erfolgreicher Sync
|
|
syncErrorMessage (text, 2000) -- Fehler-Details
|
|
syncRetryCount (int) -- Anzahl Retry-Versuche
|
|
```
|
|
|
|
**WICHTIG: Change Detection via rowId**
|
|
- Advoware's `rowId` Feld ändert sich bei **jedem** Update
|
|
- **EINZIGE** Methode für Advoware Change Detection (Advoware liefert keine Timestamps!)
|
|
- Base64-kodierte Binary-ID (~40 Zeichen), sehr zuverlässig
|
|
|
|
### 2. Mapper erstellen
|
|
```python
|
|
# services/xyz_mapper.py
|
|
class XYZMapper:
|
|
@staticmethod
|
|
def map_espo_to_advoware(espo_entity: Dict) -> Dict:
|
|
"""EspoCRM → Advoware transformation"""
|
|
return {
|
|
'field1': espo_entity.get('espoField1'),
|
|
'field2': espo_entity.get('espoField2'),
|
|
# Nur relevante Felder mappen!
|
|
}
|
|
|
|
@staticmethod
|
|
def map_advoware_to_espo(advo_entity: Dict) -> Dict:
|
|
"""Advoware → EspoCRM transformation"""
|
|
return {
|
|
'espoField1': advo_entity.get('field1'),
|
|
'espoField2': advo_entity.get('field2'),
|
|
'advowareRowId': advo_entity.get('rowId'), # WICHTIG für Change Detection!
|
|
}
|
|
```
|
|
|
|
### 3. Sync Utils erstellen
|
|
```python
|
|
# services/xyz_sync_utils.py
|
|
import redis
|
|
from typing import Dict, Any, Optional
|
|
from datetime import datetime
|
|
import pytz
|
|
|
|
MAX_SYNC_RETRIES = 5
|
|
LOCK_TTL_SECONDS = 300
|
|
|
|
class XYZSync:
|
|
def __init__(self, espocrm_api, redis_client: redis.Redis, context=None):
|
|
self.espocrm = espocrm_api
|
|
self.redis = redis_client
|
|
self.context = context
|
|
|
|
async def acquire_sync_lock(self, entity_id: str) -> bool:
|
|
"""Atomic distributed lock via Redis"""
|
|
if self.redis:
|
|
lock_key = f"sync_lock:xyz:{entity_id}"
|
|
acquired = self.redis.set(lock_key, "locked", nx=True, ex=LOCK_TTL_SECONDS)
|
|
if not acquired:
|
|
return False
|
|
|
|
await self.espocrm.update_entity('XYZ', entity_id, {'syncStatus': 'syncing'})
|
|
return True
|
|
|
|
async def release_sync_lock(
|
|
self,
|
|
entity_id: str,
|
|
new_status: str = 'clean',
|
|
error_message: Optional[str] = None,
|
|
increment_retry: bool = False,
|
|
extra_fields: Optional[Dict[str, Any]] = None
|
|
):
|
|
"""
|
|
Release lock and update status (combined operation)
|
|
|
|
WICHTIG: extra_fields verwenden um advowareRowId nach jedem Sync zu speichern!
|
|
"""
|
|
# EspoCRM DateTime Format: 'YYYY-MM-DD HH:MM:SS' (kein Timezone!)
|
|
now_utc = datetime.now(pytz.UTC)
|
|
espocrm_timestamp = now_utc.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
update_data = {
|
|
'syncStatus': new_status,
|
|
'advowareLastSync': espocrm_timestamp
|
|
}
|
|
|
|
if error_message:
|
|
update_data['syncErrorMessage'] = error_message[:2000]
|
|
else:
|
|
update_data['syncErrorMessage'] = None
|
|
|
|
if increment_retry:
|
|
entity = await self.espocrm.get_entity('XYZ', entity_id)
|
|
retry_count = (entity.get('syncRetryCount') or 0) + 1
|
|
update_data['syncRetryCount'] = retry_count
|
|
|
|
if retry_count >= MAX_SYNC_RETRIES:
|
|
update_data['syncStatus'] = 'permanently_failed'
|
|
await self.send_notification(
|
|
entity_id,
|
|
f"Sync failed after {MAX_SYNC_RETRIES} attempts"
|
|
)
|
|
else:
|
|
update_data['syncRetryCount'] = 0
|
|
|
|
if extra_fields:
|
|
update_data.update(extra_fields)
|
|
|
|
await self.espocrm.update_entity('XYZ', entity_id, update_data)
|
|
|
|
if self.redis:
|
|
self.redis.delete(f"sync_lock:xyz:{entity_id}")
|
|
entities(self, espo_entity: Dict, advo_entity: Dict) -> str:
|
|
"""
|
|
Vergleicht EspoCRM und Advoware Entity mit rowId-basierter Change Detection.
|
|
|
|
PRIMÄR: rowId-Vergleich (Advoware rowId ändert sich bei jedem Update)
|
|
FALLBACK: Timestamp-Vergleich (wenn rowId nicht verfügbar)
|
|
|
|
Logik:
|
|
- rowId geändert + EspoCRM geändert (modifiedAt > lastSync) → conflict
|
|
- Nur rowId geändert → advoware_newer
|
|
- Nur EspoCRM geändert → espocrm_newer
|
|
- Keine Änderung → no_change
|
|
|
|
Returns:
|
|
"espocrm_newer": EspoCRM wurde geändert
|
|
"advoware_newer": Advoware wurde geändert
|
|
"conflict": Beide wurden geändert
|
|
"no_change": Keine Änderungen
|
|
"""
|
|
espo_rowid = espo_entity.get('advowareRowId')
|
|
advo_rowid = advo_entity.get('rowId')
|
|
last_sync = espo_entity.get('advowareLastSync')
|
|
espo_modified = espo_entity.get('modifiedAt')
|
|
|
|
# PRIMÄR: rowId-basierte Änderungserkennung (sehr zuverlässig!)
|
|
if espo_rowid and advo_rowid and last_sync:
|
|
# Prüfe ob Advoware geändert wurde (rowId)
|
|
advo_changed = (espo_rowid != advo_rowid)
|
|
|
|
# Prüfe ob EspoCRM auch geändert wurde (seit letztem Sync)
|
|
espo_changed = False
|
|
if espo_modified:
|
|
try:
|
|
espo_ts = self._parse_ts(espo_modified)
|
|
sync_ts = self._parse_ts(last_sync)
|
|
if espo_ts and sync_ts:
|
|
espo_changed = (espo_ts > sync_ts)
|
|
except Exception as e:
|
|
self._log(f"Timestamp-Parse-Fehler: {e}", level='debug')
|
|
|
|
# Konfliktlogik
|
|
if advo_changed and espo_changed:
|
|
self._log(f"🚨 KONFLIKT: Beide Seiten geändert seit letztem Sync")
|
|
return 'conflict'
|
|
elif advo_changed:
|
|
self._log(f"Advoware rowId geändert: {espo_rowid[:20]}... → {advo_rowid[:20]}...")
|
|
return 'advoware_newer'
|
|
elif espo_changed:
|
|
self._log(f"EspoCRM neuer (modifiedAt > lastSync)")
|
|
return 'espocrm_newer'
|
|
else:
|
|
# Weder Advoware noch EspoCRM geändert
|
|
return 'no_change'
|
|
|
|
# FALLBACK: Timestamp-Vergleich (wenn rowId nicht verfügbar)
|
|
self._log("⚠️ rowId nicht verfügbar, fallback auf Timestamp-Vergleich", level='warn')
|
|
return self.compare_timestamps(
|
|
espo_entity.get('modifiedAt'),
|
|
advo_entity.get('geaendertAm'), # Advoware Timestamp-Feld
|
|
espo_entity.get('advowareLastSync')
|
|
)
|
|
|
|
def compare_timestamps(self, espo_ts, advo_ts, last_sync_ts):
|
|
"""
|
|
FALLBACK: Timestamp-basierte Änderungserkennung
|
|
|
|
ACHTUNG: Weniger zuverlässig als rowId (Timestamps können NULL sein)
|
|
Nur verwenden wenn rowId nicht verfügbar!
|
|
nc_ts):
|
|
"""Compare timestamps and determine sync direction"""
|
|
# Parse timestamps
|
|
espo = self._parse_ts(espo_ts)
|
|
advo = self._parse_ts(advo_ts)
|
|
sync = self._parse_ts(last_sync_ts)
|
|
|
|
if not sync:
|
|
if not espo or not advo:
|
|
return "no_change"
|
|
return "espocrm_newer" if espo > advo else "advoware_newer"
|
|
|
|
espo_changed = espo and espo > sync
|
|
advo_changed = advo and advo > sync
|
|
|
|
if espo_changed and advo_changed:
|
|
return "conflict"
|
|
elif espo_changed:
|
|
return "espocrm_newer"
|
|
elif advo_changed:
|
|
return "advoware_newer"
|
|
else:
|
|
return "no_change"
|
|
|
|
def merge_for_advoware_put(self, advo_entity, espo_entity, mapper):
|
|
"""Merge EspoCRM updates into Advoware entity (Read-Modify-Write)"""
|
|
advo_updates = mapper.map_espo_to_advoware(espo_entity)
|
|
merged = {**advo_entity, **advo_updates}
|
|
|
|
self._log(f"📝 Merge: {len(advo_updates)} updates → {len(merged)} total")
|
|
return merged
|
|
|
|
async def send_notification(self, entity_id, message):
|
|
"""Send in-app notification to EspoCRM"""
|
|
# Implementation...
|
|
pass
|
|
|
|
def _parse_ts(self, ts):
|
|
"""Parse timestamp string to datetime"""
|
|
# Implementation...
|
|
pass
|
|
|
|
def _log(self, msg, level='info'):
|
|
"""Log with context support"""
|
|
if self.context:
|
|
getattr(self.context.logger, level)(msg)
|
|
```
|
|
|
|
### 4. Event Handler erstellen
|
|
```python
|
|
# steps/vmh/xyz_sync_event_step.py
|
|
from services.advoware import AdvowareAPI
|
|
from services.espocrm import EspoCRMAPI
|
|
from services.xyz_mapper import XYZMapper
|
|
from services.xyz_sync_utils import XYZSync
|
|
import redis
|
|
from config import Config
|
|
|
|
config = {
|
|
'type': 'event',
|
|
'name': 'VMH XYZ Sync Handler',
|
|
'description': 'Bidirectional sync for XYZ entities',
|
|
'subscribes': [
|
|
'vmh.xyz.create',
|
|
'vmh.xyz.update',
|
|
'vmh.xyz.delete',
|
|
'vmh.xyz.sync_check'
|
|
],
|
|
'flows': ['vmh']
|
|
}
|
|
|
|
async def handler(event_data, context):
|
|
entity_id = event_data.get('entity_id')
|
|
action = event_data.get('action', 'sync_check')
|
|
|
|
if not entity_id:
|
|
context.logger.error("No entity_id in event")
|
|
return
|
|
|
|
# Initialize
|
|
redis_client = redis.Redis(
|
|
host=Config.REDIS_HOST,
|
|
port=int(Config.REDIS_PORT),
|
|
db=int(Config.REDIS_DB_ADVOWARE_CACHE),
|
|
decode_responses=True
|
|
)
|
|
|
|
espocrm = EspoCRMAPI()
|
|
advoware = AdvowareAPI(context)
|
|
sync_utils = XYZSync(espocrm, redis_client, context)
|
|
mapper = XYZMapper()
|
|
|
|
try:
|
|
# Acquire lock
|
|
if not await sync_utils.acquire_sync_lock(entity_id):
|
|
context.logger.warning(f"Already syncing: {entity_id}")
|
|
return
|
|
|
|
# Load entity
|
|
espo_entity = await espocrm.get_entity('XYZ', entity_id)
|
|
advoware_id = espo_entity.get('advowareId')
|
|
|
|
# Route to handler
|
|
if not advoware_id and action in ['create', 'sync_check']:
|
|
await handle_create(entity_id, espo_entity, espocrm, advoware, sync_utils, mapper, context)
|
|
elif advoware_id:
|
|
await handle_update(entity_id, advoware_id, espo_entity, espocrm, advoware, sync_utils, mapper, context)
|
|
|
|
except Exception as e:
|
|
context.logger.error(f"Sync failed: {e}")
|
|
await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True)
|
|
|
|
|
|
async def handle_create(entity_id, espo_entity, espocrm, advoware, sync_utils, mapper, context):
|
|
"""Create new entity in Advoware"""
|
|
try:
|
|
advo_data = mapper.map_espo_to_advoware(espo_entity)
|
|
|
|
result = await advoware.api_call(
|
|
'api/v1/advonet/XYZ',
|
|
WICHTIG: Lade Entity nach POST um rowId zu bekommen
|
|
created_entity = await advoware.api_call(
|
|
f'api/v1/advonet/XYZ/{new_id}',
|
|
method='GET'
|
|
)
|
|
new_rowid = created_entity.get('rowId') if isinstance(created_entity, dict) else created_entity[0].get('rowId')
|
|
|
|
# Combined API call: release lock + save foreign key + rowId
|
|
await sync_utils.release_sync_lock(
|
|
entity_id,
|
|
'clean',
|
|
extra_fields={
|
|
'advowareId': new_id,
|
|
'advowareRowId': new_rowid # WICHTIG für Change Detection!
|
|
}
|
|
)
|
|
|
|
context.logger.info(f"✅ Created in Advoware: {new_id} (rowId: {new_rowid[:20]}...)
|
|
# Combined API call: release lock + save foreign key
|
|
await sync_utils.release_sync_lock(
|
|
entity_id,
|
|
'clean',
|
|
extra_fields={'advowareId': new_id}
|
|
)
|
|
|
|
context.logger.info(f"✅ Created in Advoware: {new_id}")
|
|
entities (rowId-basiert, NICHT nur Timestamps!)
|
|
comparison = sync_utils.compare_entities(espo_entity, advo_entity
|
|
async def handle_update(entity_id, advoware_id, espo_entity, espocrm, advoware, sync_utils, mapper, context):
|
|
"""Sync existing entity"""
|
|
try:
|
|
# Fetch from Advoware
|
|
advo_result = await advoware.api_call(f'api/v1/advonet/XYZ/{advoware_id}', method='GET')
|
|
advo_entity = advo_result[0] if isinstance(advo_result, list) else advo_result
|
|
|
|
if not advo_entity:
|
|
context.logger.error(f"Entity not found in Advoware: {advoware_id}")
|
|
await sync_utils.release_sync_lock(entity_id, 'failed', "Not found in Advoware")
|
|
return
|
|
|
|
# Compare timestamps
|
|
comparison = sync_utils.compa - Merge EspoCRM → Advoware
|
|
if not espo_entity.get('advowareLastSync'):
|
|
merged_data = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
|
|
await advoware.api_call(f'api/v1/advonet/XYZ/{advoware_id}', method='PUT', data=merged_data)
|
|
|
|
# Lade Entity nach PUT um neue rowId zu bekommen
|
|
updated_entity = await advoware.api_call(f'api/v1/advonet/XYZ/{advoware_id}', method='GET')
|
|
new_rowid = updated_entity.get('rowId') if isinstance(updated_entity, dict) else updated_entity[0].get('rowId')
|
|
|
|
await sync_utils.release_sync_lock(entity_id, 'clean', extra_fields={'advowareRowId': new_rowid}
|
|
|
|
# Initial sync (no last_sync)
|
|
if not espo_ent → Update Advoware
|
|
if comparison == 'espocrm_newer':
|
|
merged_data = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
|
|
await advoware.api_call(f'api/v1/advonet/XYZ/{advoware_id}', method='PUT', data=merged_data)
|
|
|
|
# WICHTIG: Lade Entity nach PUT um neue rowId zu bekommen
|
|
updated_entity = await advoware.api_call(f'api/v1/advonet/XYZ/{advoware_id}', method='GET')
|
|
new_rowid = updated_entity.get('rowId') if isinstance(updated_entity, dict) else updated_entity[0].get('rowId')
|
|
|
|
await sync_utils.release_sync_lock(entity_id, 'clean', extra_fields={'advowareRowId': new_rowid})
|
|
|
|
# Advoware newer → Update EspoCRM
|
|
elif comparison == 'advoware_newer':
|
|
espo_data = mapper.map_advoware_to_espo(advo_entity) # Enthält bereits rowId!
|
|
await espocrm.update_entity('XYZ', entity_id, espo_data)
|
|
await sync_utils.release_sync_lock(entity_id, 'clean')
|
|
|
|
# Conflict → EspoCRM wins
|
|
elif comparison == 'conflict':
|
|
merged_data = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
|
|
await advoware.api_call(f'api/v1/advonet/XYZ/{advoware_id}', method='PUT', data=merged_data)
|
|
|
|
# WICHTIG: Auch bei Konflikt rowId aktualisieren
|
|
updated_entity = await advoware.api_call(f'api/v1/advonet/XYZ/{advoware_id}', method='GET')
|
|
new_rowid = updated_entity.get('rowId') if isinstance(updated_entity, dict) else updated_entity[0].get('rowId')
|
|
|
|
await sync_utils.send_notification(entity_id, "Conflict resolved: EspoCRM won")
|
|
await sync_utils.release_sync_lock(entity_id, 'clean', extra_fields={'advowareRowId': new_rowid}
|
|
elif comparison == 'advoware_newer':
|
|
espo_data = mapper.map_advoware_to_espo(advo_entity)
|
|
await espocrm.update_entity('XYZ', entity_id, espo_data)
|
|
await sync_utils.release_sync_lock(entity_id, 'clean')
|
|
|
|
# Conflict → EspoCRM wins
|
|
elif comparison == 'conflict':
|
|
merged_data = sync_utils.merge_for_advoware_put(advo_entity, espo_entity, mapper)
|
|
await advoware.api_call(f'api/v1/advonet/XYZ/{advoware_id}', method='PUT', data=merged_data)
|
|
await sync_utils.send_notification(entity_id, "Conflict resolved: EspoCRM won")
|
|
await sync_utils.release_sync_lock(entity_id, 'clean')
|
|
|
|
except Exception as e:
|
|
context.logger.error(f"❌ Update failed: {e}")
|
|
await sync_utils.release_sync_lock(entity_id, 'failed', str(e), increment_retry=True)
|
|
```
|
|
|
|
### 5. Cron erstellen
|
|
```python
|
|
# steps/vmh/xyz_sync_cron_step.py
|
|
import asyncio
|
|
from services.espocrm import EspoCRMAPI
|
|
import datetime
|
|
|
|
config = {
|
|
'type': 'cron',
|
|
'name': 'VMH XYZ Sync Cron',
|
|
'description': 'Check for XYZ entities needing sync',
|
|
'schedule': '*/15 * * * *', # Every 15 minutes
|
|
'flows': ['vmh'],
|
|
'emits': ['vmh.xyz.sync_check']
|
|
}
|
|
|
|
async def handler(context):
|
|
context.logger.info("🕐 XYZ Sync Cron started")
|
|
|
|
espocrm = EspoCRMAPI()
|
|
threshold = datetime.datetime.now() - datetime.timedelta(hours=24)
|
|
|
|
# Find entities needing sync
|
|
unclean_filter = {
|
|
'where': [{
|
|
'type': 'or',
|
|
'value': [
|
|
{'type': 'equals', 'attribute': 'syncStatus', 'value': 'pending_sync'},
|
|
{'type': 'equals', 'attribute': 'syncStatus', 'value': 'dirty'},
|
|
{'type': 'equals', 'attribute': 'syncStatus', 'value': 'failed'},
|
|
]
|
|
}]
|
|
}
|
|
|
|
result = await espocrm.search_entities('XYZ', unclean_filter, max_size=100)
|
|
entities = result.get('list', [])
|
|
entity_ids = [e['id'] for e in entities]
|
|
|
|
context.logger.info(f"Found {len(entity_ids)} entities to sync")
|
|
|
|
if not entity_ids:
|
|
return
|
|
|
|
# Batch emit (parallel)
|
|
tasks = [
|
|
context.emit({
|
|
'topic': 'vmh.xyz.sync_check',
|
|
'data': {
|
|
'entity_id': eid,
|
|
'action': 'sync_check',
|
|
'source': 'cron',
|
|
'timestamp': datetime.datetime.now().isoformat()
|
|
}
|
|
})
|
|
for eid in entity_ids
|
|
]
|
|
|
|
results = await asyncio.gather(*tasks, return_exceptions=True)
|
|
success_count = sum(1 for r in results if not isinstance(r, Exception))
|
|
|
|
context.logger.info(f"✅ Emitted {success_count}/{len(entity_ids)} events")
|
|
```
|
|
|
|
## Best Practices
|
|
|
|
### ✅ DO
|
|
- Use Redis distributed lock (atomicity)
|
|
- Combine API calls with `extra_fields`
|
|
- Use `merge_for_advoware_put()` utility
|
|
- Implement max retries (5x)
|
|
- Batch emit in cron with `asyncio.gather()`
|
|
- Map only relevant fields (avoid overhead)
|
|
- Add proper error logging
|
|
|
|
### ❌ DON'T
|
|
- Don't use GET-then-PUT for locks (race condition)
|
|
- Don't make unnecessary API calls
|
|
- Don't duplicate merge logic
|
|
- Don't retry infinitely
|
|
- Don't emit events sequentially in cron
|
|
- Don't map every field (performance)
|
|
- Don't swallow exceptions silently
|
|
- Don't rely on Advoware timestamps (nicht vorhanden!)
|
|
|
|
## Architecture Principles
|
|
|
|
1. **Atomicity**: Redis lock + TTL
|
|
2. **Efficiency**: Combined operations
|
|
3. **Reusability**: Utility functions
|
|
4. **Robustness**: Max retries + notifications
|
|
5. **Scalability**: Batch processing
|
|
6. **Maintainability**: Clear separation of concerns
|
|
7. **Reliability**: rowId-basierte Change Detection (EINZIGE Methode)
|
|
|
|
## Change Detection Details
|
|
|
|
### rowId-basierte Erkennung (EINZIGE METHODE)
|
|
|
|
**Warum nur rowId?**
|
|
- Advoware liefert **KEINE** Timestamps (geaendertAm, modifiedAt etc.)
|
|
- Advoware's `rowId` Feld ändert sich bei **jedem** Update der Entity
|
|
- Base64-kodierte Binary-ID (~40 Zeichen)
|
|
- Sehr zuverlässig, keine Timezone-Probleme, keine NULL-Werte
|
|
|
|
**Implementierung:**
|
|
```python
|
|
# 1. EspoCRM Feld: advowareRowId (varchar 50)
|
|
# 2. Im Mapper IMMER rowId mitmappen:
|
|
'advowareRowId': advo_entity.get('rowId')
|
|
|
|
# 3. Nach JEDEM Sync rowId in EspoCRM speichern:
|
|
await sync_utils.release_sync_lock(
|
|
entity_id,
|
|
'clean',
|
|
extra_fields={'advowareRowId': new_rowid}
|
|
)
|
|
|
|
# 4. Bei Änderungserkennung:
|
|
if espo_rowid != advo_rowid:
|
|
# Advoware wurde geändert!
|
|
if espo_modified > last_sync:
|
|
# Konflikt: Beide Seiten geändert
|
|
return 'conflict'
|
|
else:
|
|
# Nur Advoware geändert
|
|
return 'advoware_newer'
|
|
```
|
|
|
|
**Wichtige Sync-Punkte für rowId:**
|
|
- Nach POST (Create) - GET aufrufen um rowId zu laden
|
|
- Nach PUT (EspoCRM → Advoware) - GET aufrufen um neue rowId zu laden
|
|
- Nach PUT (Konfliktlösung) - GET aufrufen um neue rowId zu laden
|
|
- Bei Advoware → EspoCRM (via Mapper) - rowId ist bereits in Advoware Response
|
|
|
|
**WICHTIG:** rowId ist PFLICHT für Change Detection! Ohne rowId können Änderungen nicht erkannt werden.
|
|
|
|
### Person vs. Firma Mapping
|
|
|
|
**Unterschiedliche Felder je nach Typ:**
|
|
|
|
```python
|
|
# EspoCRM Struktur:
|
|
# - Natürliche Person: firstName, lastName (firmenname=None)
|
|
# - Firma: firmenname (firstName=None, lastName=None)
|
|
|
|
def map_advoware_to_espo(advo_entity):
|
|
vorname = advo_entity.get('vorname')
|
|
is_person = bool(vorname and vorname.strip())
|
|
|
|
if is_person:
|
|
# Natürliche Person
|
|
return {
|
|
'firstName': vorname,
|
|
'lastName': advo_entity.get('name'),
|
|
'name': f"{vorname} {advo_entity.get('name')}".strip(),
|
|
'firmenname': None
|
|
}
|
|
else:
|
|
# Firma
|
|
return {
|
|
'firmenname': advo_entity.get('name'),
|
|
'name': advo_entity.get('name'),
|
|
'firstName': None,
|
|
'lastName': None # EspoCRM blendet aus bei Firmen
|
|
}
|
|
```
|
|
|
|
**Wichtig:** EspoCRM blendet `firstName/lastName` im Frontend aus wenn `firmenname` gefüllt ist. Daher sauber trennen!
|
|
- Don't map every field (performance)
|
|
- Don't swallow exceptions silently
|
|
|
|
## Architecture Principles
|
|
|
|
1. **Atomicity**: Redis lock + TTL
|
|
2. **Efficiency**: Combined operations
|
|
3. **Reusability**: Utility functions
|
|
4. **Robustness**: Max retries + notifications
|
|
5. **Scalability**: Batch processing
|
|
6. **Maintainability**: Clear separation of concerns
|
|
|
|
## Performance Targets
|
|
|
|
| Metric | Target |
|
|
|--------|--------|
|
|
| Single sync latency | < 500ms |
|
|
| API calls per operation | ≤ 3 |
|
|
| Cron execution (100 entities) | < 2s |
|
|
| Lock timeout | 5 min |
|
|
| Max retries | 5 |
|
|
|
|
## Testing
|
|
|
|
```python
|
|
# Test script template
|
|
async def main():
|
|
entity_id = 'test-id'
|
|
espo = EspoCRMAPI()
|
|
|
|
# Reset entity
|
|
await espo.update_entity('XYZ', entity_id, {
|
|
'advowareLastSync': None,
|
|
'syncStatus': 'clean',
|
|
'syncRetryCount': 0
|
|
})
|
|
|
|
# Trigger sync
|
|
event_data = {
|
|
'entity_id': entity_id,
|
|
'action': 'sync_check',
|
|
'source': 'test'
|
|
}
|
|
|
|
await xyz_sync_event_step.handler(event_data, MockContext())
|
|
|
|
# Verify
|
|
entity_after = await espo.get_entity('XYZ', entity_id)
|
|
assert entity_after['syncStatus'] == 'clean'
|
|
```
|
|
|
|
## Siehe auch
|
|
|
|
- [Beteiligte Sync](BETEILIGTE_SYNC.md) - Reference implementation
|
|
- [Advoware API Docs](advoware/)
|
|
- [EspoCRM API Docs](API.md)
|