Organized
This commit is contained in:
@@ -0,0 +1,230 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user