231 lines
7.5 KiB
Python
231 lines
7.5 KiB
Python
"""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
|