"""CRM upsert client supporting idempotency by Redmine ID and email fallback.""" from __future__ import annotations import logging from dataclasses import dataclass from typing import Any, Callable, Dict, List, Optional logger = logging.getLogger(__name__) class CrmError(Exception): """Base exception for CRM errors.""" def __init__( self, message: str, *, redmine_id: Optional[int] = None, crm_id: Optional[str] = None, ) -> None: super().__init__(message) self.redmine_id = redmine_id self.crm_id = crm_id @dataclass(frozen=True, slots=True) class UpsertResult: crm_id: str created: bool updated: bool skipped_unchanged: bool redmine_contact_id: int class CrmClient: """Generic CRM upsert wrapper. Provides primitives for local testing; real CRM adapters should subclass and implement _find_by_redmine_id, _find_by_email, _create, and _update. """ # Shared simulated CRM store for the in-memory adapter. _store: Dict[str, Dict[str, Any]] = {} def __init__( self, *, api_key: Optional[str] = None, base_url: Optional[str] = None, find_by_redmine_id: Optional[Callable[[int], Optional[Dict[str, Any]]]] = None, find_by_email: Optional[Callable[[str], Optional[Dict[str, Any]]]] = None, create: Optional[Callable[[Dict[str, Any]], str]] = None, update: Optional[Callable[[str, Dict[str, Any]], None]] = None, ) -> None: self.api_key = api_key self.base_url = base_url self._find_by_redmine_id_fn = find_by_redmine_id self._find_by_email_fn = find_by_email self._create_fn = create self._update_fn = update # --- Extensibility hooks (override in subclasses) ----------------------- def _find_by_redmine_id(self, redmine_id: int) -> Optional[Dict[str, Any]]: if self._find_by_redmine_id_fn: return self._find_by_redmine_id_fn(redmine_id) for record in self._store.values(): if record.get("redmine_contact_id") == redmine_id: return record return None def _find_by_email(self, email: str) -> Optional[Dict[str, Any]]: if self._find_by_email_fn: return self._find_by_email_fn(email) normalized = email.lower().strip() for record in self._store.values(): for entry in record.get("emails", []): if entry.get("value", "").lower().strip() == normalized: return record return None def _create(self, record: Dict[str, Any]) -> str: if self._create_fn: return self._create_fn(record) crm_id = f"crm::{record['redmine_contact_id']}" self._store[crm_id] = record.copy() return crm_id def _update(self, crm_id: str, record: Dict[str, Any]) -> None: if self._update_fn: self._update_fn(crm_id, record) return self._store[crm_id] = record.copy() # --- Public API --------------------------------------------------------- def _normalize_email(self, email: Optional[str]) -> Optional[str]: if not email: return None return email.lower().strip() def _has_changed( self, existing: Dict[str, Any], candidate: Dict[str, Any], ) -> bool: """Return True if meaningful Redmine data changed.""" if not existing: return True candidate_ts = candidate.get("last_redmine_update") existing_ts = existing.get("last_redmine_update") if candidate_ts and existing_ts: return candidate_ts != existing_ts # Fallback to structural comparison for fields we care about. keys = { "full_name", "company", "title", "emails", "phones", "urls", "address", "notes", "tags", "birthday", } for key in keys: if existing.get(key) != candidate.get(key): return True return False def upsert(self, record: Dict[str, Any]) -> UpsertResult: """Idempotently upsert a mapped Redmine contact into the CRM.""" redmine_id = record.get("redmine_contact_id") if redmine_id is None: raise CrmError("Record missing redmine_contact_id") existing = self._find_by_redmine_id(redmine_id) fallback_email: Optional[str] = None if not existing and record.get("emails"): fallback_email = self._normalize_email(record["emails"][0]["value"]) if fallback_email: existing = self._find_by_email(fallback_email) if existing: crm_id = existing["crm_id"] if not crm_id: raise CrmError( "Existing CRM record missing crm_id", redmine_id=redmine_id, ) if not self._has_changed(existing, record): logger.info( "CRM record %s unchanged for Redmine contact %s; skipping", crm_id, redmine_id, ) return UpsertResult( crm_id=crm_id, created=False, updated=False, skipped_unchanged=True, redmine_contact_id=redmine_id, ) record["crm_id"] = crm_id if fallback_email and not existing.get("redmine_contact_id"): # If matched by email, attach Redmine ID to close the loop. logger.info( "Matched Redmine contact %s by email %s; updating CRM record %s", redmine_id, fallback_email, crm_id, ) self._update(crm_id, record) return UpsertResult( crm_id=crm_id, created=False, updated=True, skipped_unchanged=False, redmine_contact_id=redmine_id, ) crm_id = self._create(record) record["crm_id"] = crm_id self._store[crm_id] = record.copy() return UpsertResult( crm_id=crm_id, created=True, updated=False, skipped_unchanged=False, redmine_contact_id=redmine_id, ) def sync_batch( self, records: List[Dict[str, Any]], ) -> Dict[str, Any]: """Sync a batch and return aggregated stats.""" stats = { "processed": 0, "created": 0, "updated": 0, "skipped": 0, "errors": 0, "error_records": [], } for record in records: stats["processed"] += 1 try: result = self.upsert(record) if result.created: stats["created"] += 1 elif result.updated: stats["updated"] += 1 else: stats["skipped"] += 1 except CrmError as exc: stats["errors"] += 1 stats["error_records"].append( { "redmine_contact_id": exc.redmine_id, "crm_id": exc.crm_id, "reason": str(exc), } ) logger.warning("CRM upsert failed: %s", exc) logger.info("CRM sync batch stats: %s", stats) return stats