Files
CRM_AGENTES_CARGA/backend/api/v1/modules/crm/activities/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

135 lines
4.7 KiB
Python

from datetime import datetime, timezone
from fastapi import HTTPException, status
from sqlalchemy.orm import Session
from ..accounts.models import Account
from ..contacts.models import Contact
from ..leads.models import Lead
from ..opportunities.models import Opportunity
from .dto import ActivityCreate, ActivityUpdate
from .models import Activity
_ALLOWED_TYPES = {"call", "meeting", "task", "email", "note"}
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
"""Valida las entidades relacionadas opcionales dentro del tenant/company."""
def scope(model, _id):
return (
db.query(model.id)
.filter(
model.id == _id,
model.tenant_id == tenant_id,
model.company_id == company_id,
model.deleted_at.is_(None),
)
.first()
)
checks = [
("account_id", Account, "La cuenta asociada no existe"),
("contact_id", Contact, "El contacto asociado no existe"),
("lead_id", Lead, "El prospecto asociado no existe"),
("opportunity_id", Opportunity, "La oportunidad asociada no existe"),
]
for field, model, message in checks:
value = data.get(field)
if value is not None and not scope(model, value):
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=message)
def get_activities(
db: Session,
tenant_id: int,
company_id: int,
activity_type: str | None = None,
activity_status: str | None = None,
account_id: int | None = None,
contact_id: int | None = None,
lead_id: int | None = None,
opportunity_id: int | None = None,
) -> list[Activity]:
query = db.query(Activity).filter(
Activity.tenant_id == tenant_id,
Activity.company_id == company_id,
Activity.deleted_at.is_(None),
)
if activity_type:
query = query.filter(Activity.activity_type == activity_type)
if activity_status:
query = query.filter(Activity.status == activity_status)
if account_id is not None:
query = query.filter(Activity.account_id == account_id)
if contact_id is not None:
query = query.filter(Activity.contact_id == contact_id)
if lead_id is not None:
query = query.filter(Activity.lead_id == lead_id)
if opportunity_id is not None:
query = query.filter(Activity.opportunity_id == opportunity_id)
return query.order_by(Activity.due_date.asc().nullslast(), Activity.created_at.desc()).all()
def get_activity(db: Session, activity_id: int, tenant_id: int, company_id: int) -> Activity:
activity = (
db.query(Activity)
.filter(
Activity.id == activity_id,
Activity.tenant_id == tenant_id,
Activity.company_id == company_id,
Activity.deleted_at.is_(None),
)
.first()
)
if not activity:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Actividad no encontrada")
return activity
def create_activity(db: Session, payload: ActivityCreate, tenant_id: int, company_id: int) -> Activity:
if payload.activity_type not in _ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tipo de actividad inválido",
)
data = payload.model_dump()
_validate_refs(db, data, tenant_id, company_id)
activity = Activity(**data, tenant_id=tenant_id, company_id=company_id)
db.add(activity)
db.commit()
db.refresh(activity)
return activity
def update_activity(
db: Session, activity_id: int, payload: ActivityUpdate, tenant_id: int, company_id: int
) -> Activity:
activity = get_activity(db, activity_id, tenant_id, company_id)
data = payload.model_dump(exclude_unset=True)
if data.get("activity_type") and data["activity_type"] not in _ALLOWED_TYPES:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail="Tipo de actividad inválido",
)
_validate_refs(db, data, tenant_id, company_id)
for field, value in data.items():
setattr(activity, field, value)
db.commit()
db.refresh(activity)
return activity
def complete_activity(db: Session, activity_id: int, tenant_id: int, company_id: int) -> Activity:
activity = get_activity(db, activity_id, tenant_id, company_id)
activity.status = "completed"
activity.completed_at = datetime.now(timezone.utc)
db.commit()
db.refresh(activity)
return activity
def delete_activity(db: Session, activity_id: int, tenant_id: int, company_id: int) -> None:
activity = get_activity(db, activity_id, tenant_id, company_id)
activity.deleted_at = datetime.now(timezone.utc)
db.commit()