Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/contacts/service.py
Aduanasoft 088a8fc4df feat(crm): dominio backend (cuentas, contactos, prospectos, embudos, oportunidades, actividades)
- Nuevo schema `crm` con 7 tablas multi-tenant (TenantScopedMixin + soft delete)
- Módulos FastAPI por dominio: models/dto/service/routes (patrón example)
- Métricas del dashboard (KPIs + embudo por etapa)
- Conversión de prospecto → cuenta/contacto/oportunidad (idempotente)
- Movimiento de oportunidad entre etapas (Kanban) con estado/probabilidad derivados
- 25 permisos registrados en PermissionRegistry
- Migración Alembic con upgrade/downgrade completos
- 24 tests de servicios (pytest) en verde

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:32:05 -06:00

99 lines
3.1 KiB
Python

from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from .dto import ContactCreate, ContactUpdate
from .models import Contact
def _validate_account(db: Session, account_id: int | None, tenant_id: int, company_id: int) -> None:
"""Verifica que la cuenta referenciada exista dentro del tenant/company."""
if account_id is None:
return
exists = (
db.query(Account.id)
.filter(
Account.id == account_id,
Account.tenant_id == tenant_id,
Account.company_id == company_id,
Account.deleted_at.is_(None),
)
.first()
)
if not exists:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="La cuenta asociada no existe",
)
def get_contacts(
db: Session,
tenant_id: int,
company_id: int,
search: str | None = None,
account_id: int | None = None,
) -> list[Contact]:
query = db.query(Contact).filter(
Contact.tenant_id == tenant_id,
Contact.company_id == company_id,
Contact.deleted_at.is_(None),
)
if account_id is not None:
query = query.filter(Contact.account_id == account_id)
if search:
pattern = f"%{search}%"
query = query.filter(
Contact.first_name.ilike(pattern)
| Contact.last_name.ilike(pattern)
| Contact.email.ilike(pattern)
)
return query.order_by(Contact.first_name.asc()).all()
def get_contact(db: Session, contact_id: int, tenant_id: int, company_id: int) -> Contact:
contact = (
db.query(Contact)
.filter(
Contact.id == contact_id,
Contact.tenant_id == tenant_id,
Contact.company_id == company_id,
Contact.deleted_at.is_(None),
)
.first()
)
if not contact:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Contacto no encontrado")
return contact
def create_contact(db: Session, payload: ContactCreate, tenant_id: int, company_id: int) -> Contact:
_validate_account(db, payload.account_id, tenant_id, company_id)
contact = Contact(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
db.add(contact)
db.commit()
db.refresh(contact)
return contact
def update_contact(
db: Session, contact_id: int, payload: ContactUpdate, tenant_id: int, company_id: int
) -> Contact:
contact = get_contact(db, contact_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if "account_id" in data:
_validate_account(db, data["account_id"], tenant_id, company_id)
for field, value in data.items():
setattr(contact, field, value)
db.commit()
db.refresh(contact)
return contact
def delete_contact(db: Session, contact_id: int, tenant_id: int, company_id: int) -> None:
contact = get_contact(db, contact_id, tenant_id, company_id)
contact.deleted_at = datetime.now(timezone.utc)
db.commit()