349 lines
11 KiB
Python
349 lines
11 KiB
Python
|
|
"""Deduplication / merge service for contacts."""
|
||
|
|
|
||
|
|
from __future__ import annotations
|
||
|
|
|
||
|
|
import uuid
|
||
|
|
from typing import Any
|
||
|
|
|
||
|
|
from sqlalchemy import select, func, text
|
||
|
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||
|
|
|
||
|
|
from app.models.contact import Contact
|
||
|
|
from app.models.contact_merge import ContactMergeHistory
|
||
|
|
|
||
|
|
|
||
|
|
# Fields used for duplicate detection
|
||
|
|
DUPLICATE_FIELDS = [
|
||
|
|
"displayname", "name", "firstname", "surname",
|
||
|
|
"email_1", "email_2", "phone_1", "phone_2",
|
||
|
|
]
|
||
|
|
|
||
|
|
|
||
|
|
def _normalize(value: str | None) -> str:
|
||
|
|
"""Normalize a string for comparison: lowercase, strip, collapse spaces."""
|
||
|
|
if not value:
|
||
|
|
return ""
|
||
|
|
return " ".join(value.lower().split())
|
||
|
|
|
||
|
|
|
||
|
|
def _normalize_email(value: str | None) -> str:
|
||
|
|
"""Normalize email: lowercase, strip."""
|
||
|
|
if not value:
|
||
|
|
return ""
|
||
|
|
return value.lower().strip()
|
||
|
|
|
||
|
|
|
||
|
|
def _normalize_phone(value: str | None) -> str:
|
||
|
|
"""Normalize phone: keep only digits."""
|
||
|
|
if not value:
|
||
|
|
return ""
|
||
|
|
return "".join(c for c in value if c.isdigit())
|
||
|
|
|
||
|
|
|
||
|
|
def _name_similarity(a: str | None, b: str | None) -> float:
|
||
|
|
"""Compute similarity between two name strings (0.0 - 1.0).
|
||
|
|
|
||
|
|
Uses a simple token-based Jaccard similarity.
|
||
|
|
"""
|
||
|
|
na = set(_normalize(a).split())
|
||
|
|
nb = set(_normalize(b).split())
|
||
|
|
if not na or not nb:
|
||
|
|
return 0.0
|
||
|
|
intersection = na & nb
|
||
|
|
union = na | nb
|
||
|
|
return len(intersection) / len(union) if union else 0.0
|
||
|
|
|
||
|
|
|
||
|
|
async def find_duplicates(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
threshold: float = 0.7,
|
||
|
|
limit: int = 50,
|
||
|
|
) -> list[dict[str, Any]]:
|
||
|
|
"""Find potential duplicate contacts within a tenant.
|
||
|
|
|
||
|
|
Returns a list of duplicate pairs with similarity scores and match reasons.
|
||
|
|
"""
|
||
|
|
result = await db.execute(
|
||
|
|
select(Contact)
|
||
|
|
.where(
|
||
|
|
Contact.tenant_id == tenant_id,
|
||
|
|
Contact.deleted_at.is_(None),
|
||
|
|
)
|
||
|
|
.order_by(Contact.displayname)
|
||
|
|
)
|
||
|
|
contacts = result.scalars().all()
|
||
|
|
|
||
|
|
duplicates: list[dict[str, Any]] = []
|
||
|
|
seen_pairs: set[tuple[str, str]] = set()
|
||
|
|
|
||
|
|
for i, c1 in enumerate(contacts):
|
||
|
|
for c2 in contacts[i + 1:]:
|
||
|
|
reasons: list[str] = []
|
||
|
|
score = 0.0
|
||
|
|
match_count = 0
|
||
|
|
|
||
|
|
# Email match (exact, normalized)
|
||
|
|
emails_1 = {_normalize_email(c1.email_1), _normalize_email(c1.email_2)} - {""}
|
||
|
|
emails_2 = {_normalize_email(c2.email_1), _normalize_email(c2.email_2)} - {""}
|
||
|
|
if emails_1 and emails_2 and emails_1 & emails_2:
|
||
|
|
reasons.append("email_match")
|
||
|
|
score += 0.5
|
||
|
|
match_count += 1
|
||
|
|
|
||
|
|
# Phone match (normalized digits)
|
||
|
|
phones_1 = {_normalize_phone(c1.phone_1), _normalize_phone(c1.phone_2)} - {""}
|
||
|
|
phones_2 = {_normalize_phone(c2.phone_1), _normalize_phone(c2.phone_2)} - {""}
|
||
|
|
if phones_1 and phones_2 and phones_1 & phones_2:
|
||
|
|
reasons.append("phone_match")
|
||
|
|
score += 0.3
|
||
|
|
match_count += 1
|
||
|
|
|
||
|
|
# Name similarity
|
||
|
|
name_sim = _name_similarity(c1.displayname, c2.displayname)
|
||
|
|
if name_sim >= threshold:
|
||
|
|
reasons.append(f"name_similarity:{name_sim:.2f}")
|
||
|
|
score += name_sim * 0.4
|
||
|
|
match_count += 1
|
||
|
|
|
||
|
|
if match_count > 0 and score >= threshold:
|
||
|
|
pair_key = (str(c1.id), str(c2.id))
|
||
|
|
if pair_key not in seen_pairs:
|
||
|
|
seen_pairs.add(pair_key)
|
||
|
|
duplicates.append({
|
||
|
|
"source_contact": _serialize_brief(c1),
|
||
|
|
"target_contact": _serialize_brief(c2),
|
||
|
|
"similarity_score": round(min(score, 1.0), 2),
|
||
|
|
"match_reasons": reasons,
|
||
|
|
})
|
||
|
|
|
||
|
|
if len(duplicates) >= limit:
|
||
|
|
return duplicates
|
||
|
|
|
||
|
|
return duplicates
|
||
|
|
|
||
|
|
|
||
|
|
def _serialize_brief(c: Contact) -> dict:
|
||
|
|
"""Serialize a contact briefly for duplicate display."""
|
||
|
|
return {
|
||
|
|
"id": str(c.id),
|
||
|
|
"type": c.type,
|
||
|
|
"displayname": c.displayname,
|
||
|
|
"name": c.name,
|
||
|
|
"firstname": c.firstname,
|
||
|
|
"surname": c.surname,
|
||
|
|
"email_1": c.email_1,
|
||
|
|
"email_2": c.email_2,
|
||
|
|
"phone_1": c.phone_1,
|
||
|
|
"phone_2": c.phone_2,
|
||
|
|
"mailing_city": c.mailing_city,
|
||
|
|
"mailing_postalcode": c.mailing_postalcode,
|
||
|
|
"created_at": c.created_at.isoformat() if c.created_at else None,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
def _serialize_full(c: Contact) -> dict:
|
||
|
|
"""Serialize a contact fully for merge comparison."""
|
||
|
|
return {
|
||
|
|
"id": str(c.id),
|
||
|
|
"type": c.type,
|
||
|
|
"displayname": c.displayname,
|
||
|
|
"name": c.name,
|
||
|
|
"firstname": c.firstname,
|
||
|
|
"surname": c.surname,
|
||
|
|
"surfix": c.surfix,
|
||
|
|
"email_1": c.email_1,
|
||
|
|
"email_2": c.email_2,
|
||
|
|
"phone_1": c.phone_1,
|
||
|
|
"phone_2": c.phone_2,
|
||
|
|
"website": c.website,
|
||
|
|
"mailing_street": c.mailing_street,
|
||
|
|
"mailing_postalcode": c.mailing_postalcode,
|
||
|
|
"mailing_city": c.mailing_city,
|
||
|
|
"mailing_country": c.mailing_country,
|
||
|
|
"note": c.projectnote,
|
||
|
|
"tags": c.tags,
|
||
|
|
"code": c.code,
|
||
|
|
"vat_code": c.vat_code,
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def merge_contacts(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
user_id: uuid.UUID,
|
||
|
|
source_id: str,
|
||
|
|
target_id: str,
|
||
|
|
field_overrides: dict[str, Any] | None = None,
|
||
|
|
note: str | None = None,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""Merge source contact into target contact.
|
||
|
|
|
||
|
|
1. Apply field overrides (if provided) to target contact.
|
||
|
|
2. Re-point entity_links from source to target.
|
||
|
|
3. Re-point tag_assignments from source to target.
|
||
|
|
4. Soft-delete the source contact.
|
||
|
|
5. Record merge history.
|
||
|
|
|
||
|
|
Returns the merge history record and updated target contact.
|
||
|
|
"""
|
||
|
|
source_uuid = uuid.UUID(source_id)
|
||
|
|
target_uuid = uuid.UUID(target_id)
|
||
|
|
|
||
|
|
# Fetch both contacts
|
||
|
|
result = await db.execute(
|
||
|
|
select(Contact).where(
|
||
|
|
Contact.id == source_uuid,
|
||
|
|
Contact.tenant_id == tenant_id,
|
||
|
|
Contact.deleted_at.is_(None),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
source = result.scalar_one_or_none()
|
||
|
|
if not source:
|
||
|
|
raise ValueError(f"Source contact {source_id} not found")
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(Contact).where(
|
||
|
|
Contact.id == target_uuid,
|
||
|
|
Contact.tenant_id == tenant_id,
|
||
|
|
Contact.deleted_at.is_(None),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
target = result.scalar_one_or_none()
|
||
|
|
if not target:
|
||
|
|
raise ValueError(f"Target contact {target_id} not found")
|
||
|
|
|
||
|
|
if source.id == target.id:
|
||
|
|
raise ValueError("Cannot merge a contact with itself")
|
||
|
|
|
||
|
|
# Track which fields were merged
|
||
|
|
merged_fields: dict[str, Any] = {}
|
||
|
|
|
||
|
|
# Apply field overrides — fields explicitly chosen by the user
|
||
|
|
if field_overrides:
|
||
|
|
for field_name, value in field_overrides.items():
|
||
|
|
if hasattr(target, field_name) and field_name not in ("id", "tenant_id", "created_at", "updated_at", "deleted_at"):
|
||
|
|
old_value = getattr(target, field_name)
|
||
|
|
setattr(target, field_name, value)
|
||
|
|
merged_fields[field_name] = {
|
||
|
|
"source_value": getattr(source, field_name, None),
|
||
|
|
"target_old_value": old_value,
|
||
|
|
"final_value": value,
|
||
|
|
}
|
||
|
|
else:
|
||
|
|
# Auto-merge: fill empty target fields from source
|
||
|
|
auto_fields = [
|
||
|
|
"email_1", "email_2", "phone_1", "phone_2", "website",
|
||
|
|
"mailing_street", "mailing_postalcode", "mailing_city", "mailing_country",
|
||
|
|
"projectnote", "tags", "code", "vat_code",
|
||
|
|
]
|
||
|
|
for field_name in auto_fields:
|
||
|
|
target_val = getattr(target, field_name, None)
|
||
|
|
source_val = getattr(source, field_name, None)
|
||
|
|
if not target_val and source_val:
|
||
|
|
setattr(target, field_name, source_val)
|
||
|
|
merged_fields[field_name] = {
|
||
|
|
"source_value": source_val,
|
||
|
|
"target_old_value": target_val,
|
||
|
|
"final_value": source_val,
|
||
|
|
}
|
||
|
|
|
||
|
|
# Re-point entity_links from source to target (raw SQL, best-effort)
|
||
|
|
try:
|
||
|
|
await db.execute(
|
||
|
|
text(
|
||
|
|
"UPDATE entity_links SET entity_id = :target_uuid "
|
||
|
|
"WHERE entity_type = 'contact' AND entity_id = :source_uuid "
|
||
|
|
"AND tenant_id = :tenant_id"
|
||
|
|
),
|
||
|
|
{"target_uuid": target_uuid, "source_uuid": source_uuid, "tenant_id": tenant_id},
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
pass # entity_links table may not exist in test context
|
||
|
|
|
||
|
|
# Re-point tag_assignments from source to target (raw SQL, best-effort)
|
||
|
|
try:
|
||
|
|
await db.execute(
|
||
|
|
text(
|
||
|
|
"UPDATE tag_assignments SET entity_id = :target_uuid "
|
||
|
|
"WHERE entity_type = 'contact' AND entity_id = :source_uuid "
|
||
|
|
"AND tenant_id = :tenant_id"
|
||
|
|
),
|
||
|
|
{"target_uuid": target_uuid, "source_uuid": source_uuid, "tenant_id": tenant_id},
|
||
|
|
)
|
||
|
|
except Exception:
|
||
|
|
pass # tag_assignments table may not exist in test context
|
||
|
|
|
||
|
|
# Soft-delete source contact
|
||
|
|
from datetime import datetime, timezone
|
||
|
|
source.deleted_at = datetime.now(timezone.utc)
|
||
|
|
|
||
|
|
# Record merge history
|
||
|
|
history = ContactMergeHistory(
|
||
|
|
tenant_id=tenant_id,
|
||
|
|
source_contact_id=source_uuid,
|
||
|
|
target_contact_id=target_uuid,
|
||
|
|
merged_fields=merged_fields,
|
||
|
|
merged_by=user_id,
|
||
|
|
note=note,
|
||
|
|
)
|
||
|
|
db.add(history)
|
||
|
|
|
||
|
|
await db.flush()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"merge_id": str(history.id),
|
||
|
|
"source_contact_id": str(source_uuid),
|
||
|
|
"target_contact_id": str(target_uuid),
|
||
|
|
"merged_fields": merged_fields,
|
||
|
|
"target_contact": _serialize_full(target),
|
||
|
|
}
|
||
|
|
|
||
|
|
|
||
|
|
async def get_merge_history(
|
||
|
|
db: AsyncSession,
|
||
|
|
tenant_id: uuid.UUID,
|
||
|
|
page: int = 1,
|
||
|
|
page_size: int = 20,
|
||
|
|
) -> dict[str, Any]:
|
||
|
|
"""Get paginated merge history for a tenant."""
|
||
|
|
offset = (page - 1) * page_size
|
||
|
|
|
||
|
|
count_result = await db.execute(
|
||
|
|
select(func.count(ContactMergeHistory.id)).where(
|
||
|
|
ContactMergeHistory.tenant_id == tenant_id,
|
||
|
|
ContactMergeHistory.deleted_at.is_(None),
|
||
|
|
)
|
||
|
|
)
|
||
|
|
total = count_result.scalar() or 0
|
||
|
|
|
||
|
|
result = await db.execute(
|
||
|
|
select(ContactMergeHistory)
|
||
|
|
.where(
|
||
|
|
ContactMergeHistory.tenant_id == tenant_id,
|
||
|
|
ContactMergeHistory.deleted_at.is_(None),
|
||
|
|
)
|
||
|
|
.order_by(ContactMergeHistory.created_at.desc())
|
||
|
|
.offset(offset)
|
||
|
|
.limit(page_size)
|
||
|
|
)
|
||
|
|
records = result.scalars().all()
|
||
|
|
|
||
|
|
return {
|
||
|
|
"items": [
|
||
|
|
{
|
||
|
|
"id": str(r.id),
|
||
|
|
"source_contact_id": str(r.source_contact_id),
|
||
|
|
"target_contact_id": str(r.target_contact_id),
|
||
|
|
"merged_fields": r.merged_fields,
|
||
|
|
"merged_by": str(r.merged_by) if r.merged_by else None,
|
||
|
|
"note": r.note,
|
||
|
|
"created_at": r.created_at.isoformat() if r.created_at else None,
|
||
|
|
}
|
||
|
|
for r in records
|
||
|
|
],
|
||
|
|
"total": total,
|
||
|
|
"page": page,
|
||
|
|
"page_size": page_size,
|
||
|
|
}
|