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>
This commit is contained in:
0
backend/api/v1/modules/crm/__init__.py
Normal file
0
backend/api/v1/modules/crm/__init__.py
Normal file
0
backend/api/v1/modules/crm/accounts/__init__.py
Normal file
0
backend/api/v1/modules/crm/accounts/__init__.py
Normal file
67
backend/api/v1/modules/crm/accounts/dto.py
Normal file
67
backend/api/v1/modules/crm/accounts/dto.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class AccountCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
trade_name: str | None = Field(None, max_length=255)
|
||||
rfc: str | None = Field(None, max_length=13)
|
||||
account_type: str | None = Field(None, max_length=40)
|
||||
industry: str | None = Field(None, max_length=120)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
website: str | None = Field(None, max_length=255)
|
||||
address: str | None = None
|
||||
city: str | None = Field(None, max_length=120)
|
||||
state: str | None = Field(None, max_length=120)
|
||||
country: str | None = Field(None, max_length=2)
|
||||
patente_aduanal: str | None = Field(None, max_length=20)
|
||||
status: str = Field("active", max_length=20)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class AccountUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
trade_name: str | None = Field(None, max_length=255)
|
||||
rfc: str | None = Field(None, max_length=13)
|
||||
account_type: str | None = Field(None, max_length=40)
|
||||
industry: str | None = Field(None, max_length=120)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
website: str | None = Field(None, max_length=255)
|
||||
address: str | None = None
|
||||
city: str | None = Field(None, max_length=120)
|
||||
state: str | None = Field(None, max_length=120)
|
||||
country: str | None = Field(None, max_length=2)
|
||||
patente_aduanal: str | None = Field(None, max_length=20)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class AccountResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
trade_name: str | None
|
||||
rfc: str | None
|
||||
account_type: str | None
|
||||
industry: str | None
|
||||
email: str | None
|
||||
phone: str | None
|
||||
website: str | None
|
||||
address: str | None
|
||||
city: str | None
|
||||
state: str | None
|
||||
country: str | None
|
||||
patente_aduanal: str | None
|
||||
status: str
|
||||
owner_user_id: str | None
|
||||
notes: str | None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
40
backend/api/v1/modules/crm/accounts/models.py
Normal file
40
backend/api/v1/modules/crm/accounts/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from sqlalchemy import Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Account(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Cuenta CRM: empresa cliente o prospecto.
|
||||
|
||||
Modela importadores, IMMEX, agencias aduanales, transportistas, etc.
|
||||
Los campos aduaneros (RFC, patente) son opcionales para no forzar datos
|
||||
en prospectos que aún no comparten información fiscal.
|
||||
"""
|
||||
|
||||
__tablename__ = "accounts"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
# Razón social (nombre legal) y nombre comercial
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
trade_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
rfc: Mapped[str | None] = mapped_column(String(13), nullable=True, index=True)
|
||||
# Tipo de cuenta: immex | agencia_aduanal | importador | exportador | transportista | otro
|
||||
account_type: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
industry: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
website: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
city: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
state: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
country: Mapped[str | None] = mapped_column(String(2), nullable=True, server_default=text("'MX'"))
|
||||
# Patente del agente aduanal (dato aduanero, no se traduce)
|
||||
patente_aduanal: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
# Estado comercial: active | inactive | prospect
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'active'"))
|
||||
# Vendedor responsable (id de usuario Keycloak / sub)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
67
backend/api/v1/modules/crm/accounts/routes.py
Normal file
67
backend/api/v1/modules/crm/accounts/routes.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import AccountCreate, AccountResponse, AccountUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/accounts", response_model=list[AccountResponse])
|
||||
def list_accounts(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None, description="Búsqueda por nombre, nombre comercial o RFC"),
|
||||
account_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_accounts(db, tenant_id, company_id, search, account_status)
|
||||
|
||||
|
||||
@router.get("/accounts/{account_id}", response_model=AccountResponse)
|
||||
def get_account(
|
||||
account_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_account(db, account_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/accounts", response_model=AccountResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_account(
|
||||
payload: AccountCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_account(db, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/accounts/{account_id}", response_model=AccountResponse)
|
||||
def update_account(
|
||||
account_id: int,
|
||||
payload: AccountUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_account(db, account_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_account(
|
||||
account_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_account(db, account_id, tenant_id, company_id)
|
||||
73
backend/api/v1/modules/crm/accounts/service.py
Normal file
73
backend/api/v1/modules/crm/accounts/service.py
Normal file
@@ -0,0 +1,73 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import AccountCreate, AccountUpdate
|
||||
from .models import Account
|
||||
|
||||
|
||||
def get_accounts(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
search: str | None = None,
|
||||
account_status: str | None = None,
|
||||
) -> list[Account]:
|
||||
query = db.query(Account).filter(
|
||||
Account.tenant_id == tenant_id,
|
||||
Account.company_id == company_id,
|
||||
Account.deleted_at.is_(None),
|
||||
)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
Account.name.ilike(pattern)
|
||||
| Account.trade_name.ilike(pattern)
|
||||
| Account.rfc.ilike(pattern)
|
||||
)
|
||||
if account_status:
|
||||
query = query.filter(Account.status == account_status)
|
||||
return query.order_by(Account.name.asc()).all()
|
||||
|
||||
|
||||
def get_account(db: Session, account_id: int, tenant_id: int, company_id: int) -> Account:
|
||||
account = (
|
||||
db.query(Account)
|
||||
.filter(
|
||||
Account.id == account_id,
|
||||
Account.tenant_id == tenant_id,
|
||||
Account.company_id == company_id,
|
||||
Account.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not account:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Cuenta no encontrada")
|
||||
return account
|
||||
|
||||
|
||||
def create_account(db: Session, payload: AccountCreate, tenant_id: int, company_id: int) -> Account:
|
||||
account = Account(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(account)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
def update_account(
|
||||
db: Session, account_id: int, payload: AccountUpdate, tenant_id: int, company_id: int
|
||||
) -> Account:
|
||||
account = get_account(db, account_id, tenant_id, company_id)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(account, field, value)
|
||||
db.commit()
|
||||
db.refresh(account)
|
||||
return account
|
||||
|
||||
|
||||
def delete_account(db: Session, account_id: int, tenant_id: int, company_id: int) -> None:
|
||||
account = get_account(db, account_id, tenant_id, company_id)
|
||||
# Soft delete: conserva el histórico comercial de la cuenta
|
||||
account.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
0
backend/api/v1/modules/crm/activities/__init__.py
Normal file
0
backend/api/v1/modules/crm/activities/__init__.py
Normal file
51
backend/api/v1/modules/crm/activities/dto.py
Normal file
51
backend/api/v1/modules/crm/activities/dto.py
Normal file
@@ -0,0 +1,51 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class ActivityCreate(BaseModel):
|
||||
activity_type: str = Field(..., max_length=20)
|
||||
subject: str = Field(..., min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
status: str = Field("pending", max_length=20)
|
||||
due_date: datetime | None = None
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
lead_id: int | None = None
|
||||
opportunity_id: int | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class ActivityUpdate(BaseModel):
|
||||
activity_type: str | None = Field(None, max_length=20)
|
||||
subject: str | None = Field(None, min_length=1, max_length=255)
|
||||
description: str | None = None
|
||||
status: str | None = Field(None, max_length=20)
|
||||
due_date: datetime | None = None
|
||||
completed_at: datetime | None = None
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
lead_id: int | None = None
|
||||
opportunity_id: int | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
|
||||
|
||||
class ActivityResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
activity_type: str
|
||||
subject: str
|
||||
description: str | None
|
||||
status: str
|
||||
due_date: datetime | None
|
||||
completed_at: datetime | None
|
||||
account_id: int | None
|
||||
contact_id: int | None
|
||||
lead_id: int | None
|
||||
opportunity_id: int | None
|
||||
owner_user_id: str | None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
40
backend/api/v1/modules/crm/activities/models.py
Normal file
40
backend/api/v1/modules/crm/activities/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import DateTime, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Activity(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Actividad CRM: llamada, reunión, tarea, correo o nota.
|
||||
|
||||
Puede enlazarse a cualquier entidad del CRM mediante las FK opcionales.
|
||||
"""
|
||||
|
||||
__tablename__ = "activities"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
# Tipo: call | meeting | task | email | note
|
||||
activity_type: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
subject: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# Estado: pending | completed | canceled
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'pending'"), index=True)
|
||||
due_date: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
contact_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.contacts.id"), nullable=True, index=True
|
||||
)
|
||||
lead_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.leads.id"), nullable=True, index=True
|
||||
)
|
||||
opportunity_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.opportunities.id"), nullable=True, index=True
|
||||
)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
85
backend/api/v1/modules/crm/activities/routes.py
Normal file
85
backend/api/v1/modules/crm/activities/routes.py
Normal file
@@ -0,0 +1,85 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import ActivityCreate, ActivityResponse, ActivityUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/activities", response_model=list[ActivityResponse])
|
||||
def list_activities(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
activity_type: str | None = Query(None, description="Filtrar por tipo"),
|
||||
activity_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
|
||||
account_id: int | None = Query(None),
|
||||
contact_id: int | None = Query(None),
|
||||
lead_id: int | None = Query(None),
|
||||
opportunity_id: int | None = Query(None),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_activities(
|
||||
db, tenant_id, company_id, activity_type, activity_status,
|
||||
account_id, contact_id, lead_id, opportunity_id,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/activities/{activity_id}", response_model=ActivityResponse)
|
||||
def get_activity(
|
||||
activity_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_activity(db, activity_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/activities", response_model=ActivityResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_activity(
|
||||
payload: ActivityCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_activity(db, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/activities/{activity_id}", response_model=ActivityResponse)
|
||||
def update_activity(
|
||||
activity_id: int,
|
||||
payload: ActivityUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_activity(db, activity_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/activities/{activity_id}/complete", response_model=ActivityResponse)
|
||||
def complete_activity(
|
||||
activity_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.complete_activity(db, activity_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.delete("/activities/{activity_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_activity(
|
||||
activity_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_activity(db, activity_id, tenant_id, company_id)
|
||||
134
backend/api/v1/modules/crm/activities/service.py
Normal file
134
backend/api/v1/modules/crm/activities/service.py
Normal file
@@ -0,0 +1,134 @@
|
||||
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()
|
||||
0
backend/api/v1/modules/crm/contacts/__init__.py
Normal file
0
backend/api/v1/modules/crm/contacts/__init__.py
Normal file
52
backend/api/v1/modules/crm/contacts/dto.py
Normal file
52
backend/api/v1/modules/crm/contacts/dto.py
Normal file
@@ -0,0 +1,52 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class ContactCreate(BaseModel):
|
||||
account_id: int | None = None
|
||||
first_name: str = Field(..., min_length=1, max_length=120)
|
||||
last_name: str | None = Field(None, max_length=120)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
mobile: str | None = Field(None, max_length=40)
|
||||
job_title: str | None = Field(None, max_length=120)
|
||||
department: str | None = Field(None, max_length=120)
|
||||
is_primary: bool = False
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ContactUpdate(BaseModel):
|
||||
account_id: int | None = None
|
||||
first_name: str | None = Field(None, min_length=1, max_length=120)
|
||||
last_name: str | None = Field(None, max_length=120)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
mobile: str | None = Field(None, max_length=40)
|
||||
job_title: str | None = Field(None, max_length=120)
|
||||
department: str | None = Field(None, max_length=120)
|
||||
is_primary: bool | None = None
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class ContactResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
account_id: int | None
|
||||
first_name: str
|
||||
last_name: str | None
|
||||
email: str | None
|
||||
phone: str | None
|
||||
mobile: str | None
|
||||
job_title: str | None
|
||||
department: str | None
|
||||
is_primary: bool
|
||||
owner_user_id: str | None
|
||||
notes: str | None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
27
backend/api/v1/modules/crm/contacts/models.py
Normal file
27
backend/api/v1/modules/crm/contacts/models.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Contact(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Contacto CRM: persona asociada (opcionalmente) a una cuenta."""
|
||||
|
||||
__tablename__ = "contacts"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
first_name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
last_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
mobile: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
job_title: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
department: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
is_primary: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
67
backend/api/v1/modules/crm/contacts/routes.py
Normal file
67
backend/api/v1/modules/crm/contacts/routes.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import ContactCreate, ContactResponse, ContactUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/contacts", response_model=list[ContactResponse])
|
||||
def list_contacts(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None, description="Búsqueda por nombre o email"),
|
||||
account_id: int | None = Query(None, description="Filtrar por cuenta"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_contacts(db, tenant_id, company_id, search, account_id)
|
||||
|
||||
|
||||
@router.get("/contacts/{contact_id}", response_model=ContactResponse)
|
||||
def get_contact(
|
||||
contact_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_contact(db, contact_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/contacts", response_model=ContactResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_contact(
|
||||
payload: ContactCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_contact(db, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/contacts/{contact_id}", response_model=ContactResponse)
|
||||
def update_contact(
|
||||
contact_id: int,
|
||||
payload: ContactUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_contact(db, contact_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.delete("/contacts/{contact_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_contact(
|
||||
contact_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_contact(db, contact_id, tenant_id, company_id)
|
||||
98
backend/api/v1/modules/crm/contacts/service.py
Normal file
98
backend/api/v1/modules/crm/contacts/service.py
Normal file
@@ -0,0 +1,98 @@
|
||||
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()
|
||||
0
backend/api/v1/modules/crm/leads/__init__.py
Normal file
0
backend/api/v1/modules/crm/leads/__init__.py
Normal file
70
backend/api/v1/modules/crm/leads/dto.py
Normal file
70
backend/api/v1/modules/crm/leads/dto.py
Normal file
@@ -0,0 +1,70 @@
|
||||
from datetime import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
|
||||
class LeadCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
contact_name: str | None = Field(None, max_length=160)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
company_name: str | None = Field(None, max_length=255)
|
||||
source: str | None = Field(None, max_length=60)
|
||||
status: str = Field("new", max_length=20)
|
||||
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class LeadUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
contact_name: str | None = Field(None, max_length=160)
|
||||
email: EmailStr | None = None
|
||||
phone: str | None = Field(None, max_length=40)
|
||||
company_name: str | None = Field(None, max_length=255)
|
||||
source: str | None = Field(None, max_length=60)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
estimated_value: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class LeadConvert(BaseModel):
|
||||
"""Parámetros de conversión de un prospecto."""
|
||||
|
||||
create_opportunity: bool = True
|
||||
opportunity_name: str | None = Field(None, max_length=255)
|
||||
pipeline_id: int | None = None
|
||||
stage_id: int | None = None
|
||||
amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
|
||||
|
||||
class LeadResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
contact_name: str | None
|
||||
email: str | None
|
||||
phone: str | None
|
||||
company_name: str | None
|
||||
source: str | None
|
||||
status: str
|
||||
estimated_value: Decimal | None
|
||||
owner_user_id: str | None
|
||||
converted_account_id: int | None
|
||||
converted_contact_id: int | None
|
||||
converted_opportunity_id: int | None
|
||||
notes: str | None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class LeadConvertResult(BaseModel):
|
||||
lead: LeadResponse
|
||||
account_id: int
|
||||
contact_id: int | None
|
||||
opportunity_id: int | None
|
||||
35
backend/api/v1/modules/crm/leads/models.py
Normal file
35
backend/api/v1/modules/crm/leads/models.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from sqlalchemy import ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Lead(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Prospecto sin calificar. Al calificarse se convierte en cuenta/contacto/oportunidad."""
|
||||
|
||||
__tablename__ = "leads"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
contact_name: Mapped[str | None] = mapped_column(String(160), nullable=True)
|
||||
email: Mapped[str | None] = mapped_column(String(255), nullable=True, index=True)
|
||||
phone: Mapped[str | None] = mapped_column(String(40), nullable=True)
|
||||
company_name: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
# Origen: web | referido | evento | llamada | email | otro
|
||||
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
# Estado: new | contacted | qualified | unqualified | converted
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'new'"), index=True)
|
||||
estimated_value: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
converted_account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True
|
||||
)
|
||||
converted_contact_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.contacts.id"), nullable=True
|
||||
)
|
||||
converted_opportunity_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.opportunities.id"), nullable=True
|
||||
)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
79
backend/api/v1/modules/crm/leads/routes.py
Normal file
79
backend/api/v1/modules/crm/leads/routes.py
Normal file
@@ -0,0 +1,79 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import LeadConvert, LeadConvertResult, LeadCreate, LeadResponse, LeadUpdate
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/leads", response_model=list[LeadResponse])
|
||||
def list_leads(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
search: str | None = Query(None, description="Búsqueda por nombre, empresa o email"),
|
||||
lead_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_leads(db, tenant_id, company_id, search, lead_status)
|
||||
|
||||
|
||||
@router.get("/leads/{lead_id}", response_model=LeadResponse)
|
||||
def get_lead(
|
||||
lead_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_lead(db, lead_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/leads", response_model=LeadResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_lead(
|
||||
payload: LeadCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_lead(db, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/leads/{lead_id}", response_model=LeadResponse)
|
||||
def update_lead(
|
||||
lead_id: int,
|
||||
payload: LeadUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_lead(db, lead_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/leads/{lead_id}/convert", response_model=LeadConvertResult)
|
||||
def convert_lead(
|
||||
lead_id: int,
|
||||
payload: LeadConvert,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.convert_lead(db, lead_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.delete("/leads/{lead_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_lead(
|
||||
lead_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_lead(db, lead_id, tenant_id, company_id)
|
||||
155
backend/api/v1/modules/crm/leads/service.py
Normal file
155
backend/api/v1/modules/crm/leads/service.py
Normal file
@@ -0,0 +1,155 @@
|
||||
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 ..opportunities.models import Opportunity
|
||||
from .dto import LeadConvert, LeadCreate, LeadUpdate
|
||||
from .models import Lead
|
||||
|
||||
|
||||
def get_leads(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
search: str | None = None,
|
||||
lead_status: str | None = None,
|
||||
) -> list[Lead]:
|
||||
query = db.query(Lead).filter(
|
||||
Lead.tenant_id == tenant_id,
|
||||
Lead.company_id == company_id,
|
||||
Lead.deleted_at.is_(None),
|
||||
)
|
||||
if lead_status:
|
||||
query = query.filter(Lead.status == lead_status)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.filter(
|
||||
Lead.name.ilike(pattern)
|
||||
| Lead.company_name.ilike(pattern)
|
||||
| Lead.email.ilike(pattern)
|
||||
)
|
||||
return query.order_by(Lead.created_at.desc()).all()
|
||||
|
||||
|
||||
def get_lead(db: Session, lead_id: int, tenant_id: int, company_id: int) -> Lead:
|
||||
lead = (
|
||||
db.query(Lead)
|
||||
.filter(
|
||||
Lead.id == lead_id,
|
||||
Lead.tenant_id == tenant_id,
|
||||
Lead.company_id == company_id,
|
||||
Lead.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not lead:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Prospecto no encontrado")
|
||||
return lead
|
||||
|
||||
|
||||
def create_lead(db: Session, payload: LeadCreate, tenant_id: int, company_id: int) -> Lead:
|
||||
lead = Lead(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(lead)
|
||||
db.commit()
|
||||
db.refresh(lead)
|
||||
return lead
|
||||
|
||||
|
||||
def update_lead(db: Session, lead_id: int, payload: LeadUpdate, tenant_id: int, company_id: int) -> Lead:
|
||||
lead = get_lead(db, lead_id, tenant_id, company_id)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(lead, field, value)
|
||||
db.commit()
|
||||
db.refresh(lead)
|
||||
return lead
|
||||
|
||||
|
||||
def delete_lead(db: Session, lead_id: int, tenant_id: int, company_id: int) -> None:
|
||||
lead = get_lead(db, lead_id, tenant_id, company_id)
|
||||
lead.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
def convert_lead(
|
||||
db: Session, lead_id: int, payload: LeadConvert, tenant_id: int, company_id: int
|
||||
) -> dict:
|
||||
"""Convierte un prospecto en cuenta (+ contacto y oportunidad opcionales).
|
||||
|
||||
Es idempotente: si el prospecto ya fue convertido, retorna las referencias existentes.
|
||||
"""
|
||||
lead = get_lead(db, lead_id, tenant_id, company_id)
|
||||
if lead.status == "converted" and lead.converted_account_id:
|
||||
return {
|
||||
"lead": lead,
|
||||
"account_id": lead.converted_account_id,
|
||||
"contact_id": lead.converted_contact_id,
|
||||
"opportunity_id": lead.converted_opportunity_id,
|
||||
}
|
||||
|
||||
# 1. Cuenta a partir de la empresa (o nombre) del prospecto
|
||||
account = Account(
|
||||
name=lead.company_name or lead.name,
|
||||
email=lead.email,
|
||||
phone=lead.phone,
|
||||
status="active",
|
||||
owner_user_id=lead.owner_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
db.add(account)
|
||||
db.flush() # necesitamos el id de la cuenta para enlazar contacto/oportunidad
|
||||
|
||||
# 2. Contacto (si el prospecto trae nombre de contacto)
|
||||
contact = None
|
||||
if lead.contact_name:
|
||||
parts = lead.contact_name.split(" ", 1)
|
||||
contact = Contact(
|
||||
account_id=account.id,
|
||||
first_name=parts[0],
|
||||
last_name=parts[1] if len(parts) > 1 else None,
|
||||
email=lead.email,
|
||||
phone=lead.phone,
|
||||
is_primary=True,
|
||||
owner_user_id=lead.owner_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
db.add(contact)
|
||||
db.flush()
|
||||
|
||||
# 3. Oportunidad (opcional)
|
||||
opportunity = None
|
||||
if payload.create_opportunity:
|
||||
opportunity = Opportunity(
|
||||
name=payload.opportunity_name or lead.name,
|
||||
account_id=account.id,
|
||||
contact_id=contact.id if contact else None,
|
||||
pipeline_id=payload.pipeline_id,
|
||||
stage_id=payload.stage_id,
|
||||
amount=payload.amount if payload.amount is not None else lead.estimated_value,
|
||||
source=lead.source,
|
||||
status="open",
|
||||
owner_user_id=lead.owner_user_id,
|
||||
tenant_id=tenant_id,
|
||||
company_id=company_id,
|
||||
)
|
||||
db.add(opportunity)
|
||||
db.flush()
|
||||
|
||||
# 4. Marcar el prospecto como convertido y enlazar
|
||||
lead.status = "converted"
|
||||
lead.converted_account_id = account.id
|
||||
lead.converted_contact_id = contact.id if contact else None
|
||||
lead.converted_opportunity_id = opportunity.id if opportunity else None
|
||||
|
||||
db.commit()
|
||||
db.refresh(lead)
|
||||
return {
|
||||
"lead": lead,
|
||||
"account_id": account.id,
|
||||
"contact_id": contact.id if contact else None,
|
||||
"opportunity_id": opportunity.id if opportunity else None,
|
||||
}
|
||||
0
backend/api/v1/modules/crm/metrics/__init__.py
Normal file
0
backend/api/v1/modules/crm/metrics/__init__.py
Normal file
24
backend/api/v1/modules/crm/metrics/dto.py
Normal file
24
backend/api/v1/modules/crm/metrics/dto.py
Normal file
@@ -0,0 +1,24 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel
|
||||
|
||||
|
||||
class StageMetric(BaseModel):
|
||||
stage_id: int
|
||||
stage_name: str
|
||||
position: int
|
||||
count: int
|
||||
value: Decimal
|
||||
|
||||
|
||||
class CrmMetricsResponse(BaseModel):
|
||||
total_accounts: int
|
||||
total_contacts: int
|
||||
total_leads: int
|
||||
open_leads: int
|
||||
open_opportunities: int
|
||||
open_pipeline_value: Decimal
|
||||
won_opportunities: int
|
||||
won_value: Decimal
|
||||
pending_activities: int
|
||||
by_stage: list[StageMetric]
|
||||
21
backend/api/v1/modules/crm/metrics/routes.py
Normal file
21
backend/api/v1/modules/crm/metrics/routes.py
Normal file
@@ -0,0 +1,21 @@
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import CrmMetricsResponse
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/metrics", response_model=CrmMetricsResponse)
|
||||
def get_metrics(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
pipeline_id: int | None = Query(None, description="Filtrar embudo del reporte por etapa"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_metrics(db, tenant_id, company_id, pipeline_id)
|
||||
98
backend/api/v1/modules/crm/metrics/service.py
Normal file
98
backend/api/v1/modules/crm/metrics/service.py
Normal file
@@ -0,0 +1,98 @@
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import and_, func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..accounts.models import Account
|
||||
from ..activities.models import Activity
|
||||
from ..contacts.models import Contact
|
||||
from ..leads.models import Lead
|
||||
from ..opportunities.models import Opportunity
|
||||
from ..pipelines.models import PipelineStage
|
||||
|
||||
|
||||
def _count(db: Session, model, tenant_id: int, company_id: int, *extra) -> int:
|
||||
query = db.query(func.count(model.id)).filter(
|
||||
model.tenant_id == tenant_id,
|
||||
model.company_id == company_id,
|
||||
model.deleted_at.is_(None),
|
||||
*extra,
|
||||
)
|
||||
return int(query.scalar() or 0)
|
||||
|
||||
|
||||
def _sum_amount(db: Session, tenant_id: int, company_id: int, *extra) -> Decimal:
|
||||
total = (
|
||||
db.query(func.coalesce(func.sum(Opportunity.amount), 0))
|
||||
.filter(
|
||||
Opportunity.tenant_id == tenant_id,
|
||||
Opportunity.company_id == company_id,
|
||||
Opportunity.deleted_at.is_(None),
|
||||
*extra,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return Decimal(total or 0)
|
||||
|
||||
|
||||
def get_metrics(db: Session, tenant_id: int, company_id: int, pipeline_id: int | None = None) -> dict:
|
||||
"""KPIs y embudo por etapa para el dashboard del CRM."""
|
||||
open_opps = _count(db, Opportunity, tenant_id, company_id, Opportunity.status == "open")
|
||||
won_opps = _count(db, Opportunity, tenant_id, company_id, Opportunity.status == "won")
|
||||
|
||||
# Embudo: oportunidades abiertas agrupadas por etapa
|
||||
stage_filters = [
|
||||
PipelineStage.tenant_id == tenant_id,
|
||||
PipelineStage.company_id == company_id,
|
||||
PipelineStage.deleted_at.is_(None),
|
||||
]
|
||||
if pipeline_id is not None:
|
||||
stage_filters.append(PipelineStage.pipeline_id == pipeline_id)
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
PipelineStage.id,
|
||||
PipelineStage.name,
|
||||
PipelineStage.position,
|
||||
func.count(Opportunity.id),
|
||||
func.coalesce(func.sum(Opportunity.amount), 0),
|
||||
)
|
||||
.outerjoin(
|
||||
Opportunity,
|
||||
and_(
|
||||
Opportunity.stage_id == PipelineStage.id,
|
||||
Opportunity.status == "open",
|
||||
Opportunity.deleted_at.is_(None),
|
||||
Opportunity.tenant_id == tenant_id,
|
||||
Opportunity.company_id == company_id,
|
||||
),
|
||||
)
|
||||
.filter(*stage_filters)
|
||||
.group_by(PipelineStage.id, PipelineStage.name, PipelineStage.position)
|
||||
.order_by(PipelineStage.position.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
by_stage = [
|
||||
{
|
||||
"stage_id": row[0],
|
||||
"stage_name": row[1],
|
||||
"position": row[2],
|
||||
"count": int(row[3] or 0),
|
||||
"value": Decimal(row[4] or 0),
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
return {
|
||||
"total_accounts": _count(db, Account, tenant_id, company_id),
|
||||
"total_contacts": _count(db, Contact, tenant_id, company_id),
|
||||
"total_leads": _count(db, Lead, tenant_id, company_id),
|
||||
"open_leads": _count(db, Lead, tenant_id, company_id, Lead.status != "converted"),
|
||||
"open_opportunities": open_opps,
|
||||
"open_pipeline_value": _sum_amount(db, tenant_id, company_id, Opportunity.status == "open"),
|
||||
"won_opportunities": won_opps,
|
||||
"won_value": _sum_amount(db, tenant_id, company_id, Opportunity.status == "won"),
|
||||
"pending_activities": _count(db, Activity, tenant_id, company_id, Activity.status == "pending"),
|
||||
"by_stage": by_stage,
|
||||
}
|
||||
67
backend/api/v1/modules/crm/opportunities/dto.py
Normal file
67
backend/api/v1/modules/crm/opportunities/dto.py
Normal file
@@ -0,0 +1,67 @@
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class OpportunityCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=255)
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
pipeline_id: int | None = None
|
||||
stage_id: int | None = None
|
||||
amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str = Field("MXN", max_length=3)
|
||||
probability: int | None = Field(None, ge=0, le=100)
|
||||
expected_close_date: date | None = None
|
||||
source: str | None = Field(None, max_length=60)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class OpportunityUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=255)
|
||||
account_id: int | None = None
|
||||
contact_id: int | None = None
|
||||
pipeline_id: int | None = None
|
||||
stage_id: int | None = None
|
||||
amount: Decimal | None = Field(None, ge=0, max_digits=14, decimal_places=2)
|
||||
currency: str | None = Field(None, max_length=3)
|
||||
probability: int | None = Field(None, ge=0, le=100)
|
||||
status: str | None = Field(None, max_length=20)
|
||||
expected_close_date: date | None = None
|
||||
lost_reason: str | None = Field(None, max_length=255)
|
||||
source: str | None = Field(None, max_length=60)
|
||||
owner_user_id: str | None = Field(None, max_length=64)
|
||||
notes: str | None = None
|
||||
|
||||
|
||||
class OpportunityMove(BaseModel):
|
||||
"""Mueve la oportunidad a otra etapa (drag & drop del Kanban)."""
|
||||
|
||||
stage_id: int
|
||||
|
||||
|
||||
class OpportunityResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
account_id: int | None
|
||||
contact_id: int | None
|
||||
pipeline_id: int | None
|
||||
stage_id: int | None
|
||||
amount: Decimal | None
|
||||
currency: str
|
||||
probability: int | None
|
||||
status: str
|
||||
expected_close_date: date | None
|
||||
closed_at: datetime | None
|
||||
lost_reason: str | None
|
||||
source: str | None
|
||||
owner_user_id: str | None
|
||||
notes: str | None
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
40
backend/api/v1/modules/crm/opportunities/models.py
Normal file
40
backend/api/v1/modules/crm/opportunities/models.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from datetime import date, datetime
|
||||
|
||||
from sqlalchemy import Date, DateTime, ForeignKey, Integer, Numeric, String, Text, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Opportunity(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Oportunidad (negocio) que avanza por las etapas de un embudo."""
|
||||
|
||||
__tablename__ = "opportunities"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(255), nullable=False)
|
||||
account_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.accounts.id"), nullable=True, index=True
|
||||
)
|
||||
contact_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.contacts.id"), nullable=True, index=True
|
||||
)
|
||||
pipeline_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.pipelines.id"), nullable=True, index=True
|
||||
)
|
||||
stage_id: Mapped[int | None] = mapped_column(
|
||||
Integer, ForeignKey("crm.pipeline_stages.id"), nullable=True, index=True
|
||||
)
|
||||
amount: Mapped[float | None] = mapped_column(Numeric(14, 2), nullable=True)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, server_default=text("'MXN'"))
|
||||
probability: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# Estado del negocio: open | won | lost
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, server_default=text("'open'"), index=True)
|
||||
expected_close_date: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
closed_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
lost_reason: Mapped[str | None] = mapped_column(String(255), nullable=True)
|
||||
source: Mapped[str | None] = mapped_column(String(60), nullable=True)
|
||||
owner_user_id: Mapped[str | None] = mapped_column(String(64), nullable=True, index=True)
|
||||
notes: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
88
backend/api/v1/modules/crm/opportunities/routes.py
Normal file
88
backend/api/v1/modules/crm/opportunities/routes.py
Normal file
@@ -0,0 +1,88 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
OpportunityCreate,
|
||||
OpportunityMove,
|
||||
OpportunityResponse,
|
||||
OpportunityUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
@router.get("/opportunities", response_model=list[OpportunityResponse])
|
||||
def list_opportunities(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
pipeline_id: int | None = Query(None, description="Filtrar por embudo"),
|
||||
stage_id: int | None = Query(None, description="Filtrar por etapa"),
|
||||
opp_status: str | None = Query(None, alias="status", description="Filtrar por estado"),
|
||||
search: str | None = Query(None, description="Búsqueda por nombre"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_opportunities(
|
||||
db, tenant_id, company_id, pipeline_id, stage_id, opp_status, search
|
||||
)
|
||||
|
||||
|
||||
@router.get("/opportunities/{opportunity_id}", response_model=OpportunityResponse)
|
||||
def get_opportunity(
|
||||
opportunity_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_opportunity(db, opportunity_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/opportunities", response_model=OpportunityResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_opportunity(
|
||||
payload: OpportunityCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_opportunity(db, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/opportunities/{opportunity_id}", response_model=OpportunityResponse)
|
||||
def update_opportunity(
|
||||
opportunity_id: int,
|
||||
payload: OpportunityUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_opportunity(db, opportunity_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/opportunities/{opportunity_id}/move", response_model=OpportunityResponse)
|
||||
def move_opportunity(
|
||||
opportunity_id: int,
|
||||
payload: OpportunityMove,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.move_opportunity(db, opportunity_id, payload.stage_id, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.delete("/opportunities/{opportunity_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_opportunity(
|
||||
opportunity_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_opportunity(db, opportunity_id, tenant_id, company_id)
|
||||
143
backend/api/v1/modules/crm/opportunities/service.py
Normal file
143
backend/api/v1/modules/crm/opportunities/service.py
Normal file
@@ -0,0 +1,143 @@
|
||||
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 ..pipelines.models import Pipeline, PipelineStage
|
||||
from .dto import OpportunityCreate, OpportunityUpdate
|
||||
from .models import Opportunity
|
||||
|
||||
|
||||
def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None:
|
||||
"""Valida que las referencias (cuenta, contacto, embudo, etapa) existan en el tenant/company."""
|
||||
scope = lambda model, _id: ( # noqa: E731
|
||||
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"),
|
||||
("pipeline_id", Pipeline, "El embudo asociado no existe"),
|
||||
("stage_id", PipelineStage, "La etapa 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_opportunities(
|
||||
db: Session,
|
||||
tenant_id: int,
|
||||
company_id: int,
|
||||
pipeline_id: int | None = None,
|
||||
stage_id: int | None = None,
|
||||
opp_status: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> list[Opportunity]:
|
||||
query = db.query(Opportunity).filter(
|
||||
Opportunity.tenant_id == tenant_id,
|
||||
Opportunity.company_id == company_id,
|
||||
Opportunity.deleted_at.is_(None),
|
||||
)
|
||||
if pipeline_id is not None:
|
||||
query = query.filter(Opportunity.pipeline_id == pipeline_id)
|
||||
if stage_id is not None:
|
||||
query = query.filter(Opportunity.stage_id == stage_id)
|
||||
if opp_status:
|
||||
query = query.filter(Opportunity.status == opp_status)
|
||||
if search:
|
||||
query = query.filter(Opportunity.name.ilike(f"%{search}%"))
|
||||
return query.order_by(Opportunity.created_at.desc()).all()
|
||||
|
||||
|
||||
def get_opportunity(db: Session, opportunity_id: int, tenant_id: int, company_id: int) -> Opportunity:
|
||||
opportunity = (
|
||||
db.query(Opportunity)
|
||||
.filter(
|
||||
Opportunity.id == opportunity_id,
|
||||
Opportunity.tenant_id == tenant_id,
|
||||
Opportunity.company_id == company_id,
|
||||
Opportunity.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not opportunity:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Oportunidad no encontrada")
|
||||
return opportunity
|
||||
|
||||
|
||||
def create_opportunity(
|
||||
db: Session, payload: OpportunityCreate, tenant_id: int, company_id: int
|
||||
) -> Opportunity:
|
||||
data = payload.model_dump()
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
opportunity = Opportunity(**data, tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(opportunity)
|
||||
db.commit()
|
||||
db.refresh(opportunity)
|
||||
return opportunity
|
||||
|
||||
|
||||
def update_opportunity(
|
||||
db: Session, opportunity_id: int, payload: OpportunityUpdate, tenant_id: int, company_id: int
|
||||
) -> Opportunity:
|
||||
opportunity = get_opportunity(db, opportunity_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
_validate_refs(db, data, tenant_id, company_id)
|
||||
for field, value in data.items():
|
||||
setattr(opportunity, field, value)
|
||||
db.commit()
|
||||
db.refresh(opportunity)
|
||||
return opportunity
|
||||
|
||||
|
||||
def move_opportunity(
|
||||
db: Session, opportunity_id: int, stage_id: int, tenant_id: int, company_id: int
|
||||
) -> Opportunity:
|
||||
"""Mueve la oportunidad a una etapa y deriva estado/probabilidad de la etapa destino."""
|
||||
opportunity = get_opportunity(db, opportunity_id, tenant_id, company_id)
|
||||
stage = (
|
||||
db.query(PipelineStage)
|
||||
.filter(
|
||||
PipelineStage.id == stage_id,
|
||||
PipelineStage.tenant_id == tenant_id,
|
||||
PipelineStage.company_id == company_id,
|
||||
PipelineStage.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not stage:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Etapa no encontrada")
|
||||
|
||||
opportunity.stage_id = stage.id
|
||||
opportunity.pipeline_id = stage.pipeline_id
|
||||
if stage.is_won:
|
||||
opportunity.status = "won"
|
||||
opportunity.probability = 100
|
||||
opportunity.closed_at = datetime.now(timezone.utc)
|
||||
elif stage.is_lost:
|
||||
opportunity.status = "lost"
|
||||
opportunity.probability = 0
|
||||
opportunity.closed_at = datetime.now(timezone.utc)
|
||||
else:
|
||||
opportunity.status = "open"
|
||||
opportunity.probability = stage.probability
|
||||
opportunity.closed_at = None
|
||||
db.commit()
|
||||
db.refresh(opportunity)
|
||||
return opportunity
|
||||
|
||||
|
||||
def delete_opportunity(db: Session, opportunity_id: int, tenant_id: int, company_id: int) -> None:
|
||||
opportunity = get_opportunity(db, opportunity_id, tenant_id, company_id)
|
||||
opportunity.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
44
backend/api/v1/modules/crm/permissions.py
Normal file
44
backend/api/v1/modules/crm/permissions.py
Normal file
@@ -0,0 +1,44 @@
|
||||
"""Registro de permisos del módulo CRM.
|
||||
|
||||
Se importa desde ``router.py`` para que los permisos queden registrados en el
|
||||
``PermissionRegistry`` al arrancar la app. Persistir en BD se hace con el
|
||||
endpoint ``POST /v1/core/permissions/sync`` o el CLI de sincronización.
|
||||
"""
|
||||
|
||||
from api.v1.modules.core.permissions.registry import registry
|
||||
|
||||
MODULE = "crm"
|
||||
|
||||
# (entidad, etiqueta legible)
|
||||
_ENTITIES = [
|
||||
("account", "cuentas"),
|
||||
("contact", "contactos"),
|
||||
("lead", "prospectos"),
|
||||
("opportunity", "oportunidades"),
|
||||
("pipeline", "embudos"),
|
||||
("activity", "actividades"),
|
||||
]
|
||||
|
||||
# (acción, verbo para la descripción)
|
||||
_ACTIONS = [
|
||||
("view", "Ver"),
|
||||
("create", "Crear"),
|
||||
("edit", "Editar"),
|
||||
("delete", "Eliminar"),
|
||||
]
|
||||
|
||||
|
||||
def register_permissions() -> None:
|
||||
"""Da de alta los permisos del CRM en el registro central."""
|
||||
registry.register(code=f"{MODULE}.access", description="Acceso al módulo CRM", module=MODULE, action="access")
|
||||
for entity, label in _ENTITIES:
|
||||
for action, verb in _ACTIONS:
|
||||
registry.register(
|
||||
code=f"{MODULE}.{entity}.{action}",
|
||||
description=f"{verb} {label}",
|
||||
module=MODULE,
|
||||
action=action,
|
||||
)
|
||||
|
||||
|
||||
register_permissions()
|
||||
0
backend/api/v1/modules/crm/pipelines/__init__.py
Normal file
0
backend/api/v1/modules/crm/pipelines/__init__.py
Normal file
58
backend/api/v1/modules/crm/pipelines/dto.py
Normal file
58
backend/api/v1/modules/crm/pipelines/dto.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
|
||||
class PipelineCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=120)
|
||||
is_default: bool = False
|
||||
|
||||
|
||||
class PipelineUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=120)
|
||||
is_default: bool | None = None
|
||||
|
||||
|
||||
class PipelineResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
is_default: bool
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
|
||||
|
||||
class StageCreate(BaseModel):
|
||||
pipeline_id: int
|
||||
name: str = Field(..., min_length=1, max_length=120)
|
||||
position: int = Field(0, ge=0)
|
||||
probability: int = Field(0, ge=0, le=100)
|
||||
is_won: bool = False
|
||||
is_lost: bool = False
|
||||
|
||||
|
||||
class StageUpdate(BaseModel):
|
||||
name: str | None = Field(None, min_length=1, max_length=120)
|
||||
position: int | None = Field(None, ge=0)
|
||||
probability: int | None = Field(None, ge=0, le=100)
|
||||
is_won: bool | None = None
|
||||
is_lost: bool | None = None
|
||||
|
||||
|
||||
class StageResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
pipeline_id: int
|
||||
name: str
|
||||
position: int
|
||||
probability: int
|
||||
is_won: bool
|
||||
is_lost: bool
|
||||
tenant_id: int
|
||||
company_id: int
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
36
backend/api/v1/modules/crm/pipelines/models.py
Normal file
36
backend/api/v1/modules/crm/pipelines/models.py
Normal file
@@ -0,0 +1,36 @@
|
||||
from sqlalchemy import Boolean, ForeignKey, Integer, String, text
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from api.v1.common.base_models import TenantScopedMixin, TimestampMixin
|
||||
from core.database import Base
|
||||
|
||||
|
||||
class Pipeline(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Embudo de ventas. Cada company puede tener varios embudos."""
|
||||
|
||||
__tablename__ = "pipelines"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
|
||||
|
||||
class PipelineStage(Base, TenantScopedMixin, TimestampMixin):
|
||||
"""Etapa de un embudo (columna del Kanban)."""
|
||||
|
||||
__tablename__ = "pipeline_stages"
|
||||
__table_args__ = {"schema": "crm"}
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, index=True)
|
||||
pipeline_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("crm.pipelines.id"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
# Posición de la columna en el Kanban (0..n)
|
||||
position: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
# Probabilidad de cierre asociada a la etapa (0-100)
|
||||
probability: Mapped[int] = mapped_column(Integer, nullable=False, server_default=text("0"))
|
||||
# Etapas terminales: ganada / perdida
|
||||
is_won: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
is_lost: Mapped[bool] = mapped_column(Boolean, nullable=False, server_default=text("false"))
|
||||
110
backend/api/v1/modules/crm/pipelines/routes.py
Normal file
110
backend/api/v1/modules/crm/pipelines/routes.py
Normal file
@@ -0,0 +1,110 @@
|
||||
from fastapi import APIRouter, Depends, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from core.database import get_core_db
|
||||
from core.security import get_current_user
|
||||
|
||||
from . import service
|
||||
from .dto import (
|
||||
PipelineCreate,
|
||||
PipelineResponse,
|
||||
PipelineUpdate,
|
||||
StageCreate,
|
||||
StageResponse,
|
||||
StageUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
# ----- Pipelines -----
|
||||
|
||||
@router.get("/pipelines", response_model=list[PipelineResponse])
|
||||
def list_pipelines(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_pipelines(db, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.post("/pipelines", response_model=PipelineResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_pipeline(
|
||||
payload: PipelineCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_pipeline(db, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/pipelines/{pipeline_id}", response_model=PipelineResponse)
|
||||
def update_pipeline(
|
||||
pipeline_id: int,
|
||||
payload: PipelineUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_pipeline(db, pipeline_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.delete("/pipelines/{pipeline_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_pipeline(
|
||||
pipeline_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_pipeline(db, pipeline_id, tenant_id, company_id)
|
||||
|
||||
|
||||
# ----- Stages -----
|
||||
|
||||
@router.get("/stages", response_model=list[StageResponse])
|
||||
def list_stages(
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
pipeline_id: int | None = Query(None, description="Filtrar por embudo"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.get_stages(db, tenant_id, company_id, pipeline_id)
|
||||
|
||||
|
||||
@router.post("/stages", response_model=StageResponse, status_code=status.HTTP_201_CREATED)
|
||||
def create_stage(
|
||||
payload: StageCreate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.create_stage(db, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.patch("/stages/{stage_id}", response_model=StageResponse)
|
||||
def update_stage(
|
||||
stage_id: int,
|
||||
payload: StageUpdate,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
return service.update_stage(db, stage_id, payload, tenant_id, company_id)
|
||||
|
||||
|
||||
@router.delete("/stages/{stage_id}", status_code=status.HTTP_204_NO_CONTENT)
|
||||
def delete_stage(
|
||||
stage_id: int,
|
||||
company_id: int = Query(..., description="Company ID"),
|
||||
current_user: dict = Depends(get_current_user),
|
||||
db: Session = Depends(get_core_db),
|
||||
):
|
||||
tenant_id = current_user["tenant_id"]
|
||||
service.delete_stage(db, stage_id, tenant_id, company_id)
|
||||
133
backend/api/v1/modules/crm/pipelines/service.py
Normal file
133
backend/api/v1/modules/crm/pipelines/service.py
Normal file
@@ -0,0 +1,133 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .dto import PipelineCreate, PipelineUpdate, StageCreate, StageUpdate
|
||||
from .models import Pipeline, PipelineStage
|
||||
|
||||
|
||||
# ----- Pipelines -----
|
||||
|
||||
def get_pipelines(db: Session, tenant_id: int, company_id: int) -> list[Pipeline]:
|
||||
return (
|
||||
db.query(Pipeline)
|
||||
.filter(
|
||||
Pipeline.tenant_id == tenant_id,
|
||||
Pipeline.company_id == company_id,
|
||||
Pipeline.deleted_at.is_(None),
|
||||
)
|
||||
.order_by(Pipeline.is_default.desc(), Pipeline.name.asc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def get_pipeline(db: Session, pipeline_id: int, tenant_id: int, company_id: int) -> Pipeline:
|
||||
pipeline = (
|
||||
db.query(Pipeline)
|
||||
.filter(
|
||||
Pipeline.id == pipeline_id,
|
||||
Pipeline.tenant_id == tenant_id,
|
||||
Pipeline.company_id == company_id,
|
||||
Pipeline.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not pipeline:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embudo no encontrado")
|
||||
return pipeline
|
||||
|
||||
|
||||
def _clear_default(db: Session, tenant_id: int, company_id: int) -> None:
|
||||
"""Solo un embudo puede ser el predeterminado por company."""
|
||||
db.query(Pipeline).filter(
|
||||
Pipeline.tenant_id == tenant_id,
|
||||
Pipeline.company_id == company_id,
|
||||
Pipeline.is_default.is_(True),
|
||||
).update({Pipeline.is_default: False})
|
||||
|
||||
|
||||
def create_pipeline(db: Session, payload: PipelineCreate, tenant_id: int, company_id: int) -> Pipeline:
|
||||
if payload.is_default:
|
||||
_clear_default(db, tenant_id, company_id)
|
||||
pipeline = Pipeline(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(pipeline)
|
||||
db.commit()
|
||||
db.refresh(pipeline)
|
||||
return pipeline
|
||||
|
||||
|
||||
def update_pipeline(
|
||||
db: Session, pipeline_id: int, payload: PipelineUpdate, tenant_id: int, company_id: int
|
||||
) -> Pipeline:
|
||||
pipeline = get_pipeline(db, pipeline_id, tenant_id, company_id)
|
||||
data = payload.model_dump(exclude_unset=True)
|
||||
if data.get("is_default"):
|
||||
_clear_default(db, tenant_id, company_id)
|
||||
for field, value in data.items():
|
||||
setattr(pipeline, field, value)
|
||||
db.commit()
|
||||
db.refresh(pipeline)
|
||||
return pipeline
|
||||
|
||||
|
||||
def delete_pipeline(db: Session, pipeline_id: int, tenant_id: int, company_id: int) -> None:
|
||||
pipeline = get_pipeline(db, pipeline_id, tenant_id, company_id)
|
||||
pipeline.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
|
||||
|
||||
# ----- Stages -----
|
||||
|
||||
def get_stages(db: Session, tenant_id: int, company_id: int, pipeline_id: int | None = None) -> list[PipelineStage]:
|
||||
query = db.query(PipelineStage).filter(
|
||||
PipelineStage.tenant_id == tenant_id,
|
||||
PipelineStage.company_id == company_id,
|
||||
PipelineStage.deleted_at.is_(None),
|
||||
)
|
||||
if pipeline_id is not None:
|
||||
query = query.filter(PipelineStage.pipeline_id == pipeline_id)
|
||||
return query.order_by(PipelineStage.position.asc()).all()
|
||||
|
||||
|
||||
def get_stage(db: Session, stage_id: int, tenant_id: int, company_id: int) -> PipelineStage:
|
||||
stage = (
|
||||
db.query(PipelineStage)
|
||||
.filter(
|
||||
PipelineStage.id == stage_id,
|
||||
PipelineStage.tenant_id == tenant_id,
|
||||
PipelineStage.company_id == company_id,
|
||||
PipelineStage.deleted_at.is_(None),
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not stage:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Etapa no encontrada")
|
||||
return stage
|
||||
|
||||
|
||||
def create_stage(db: Session, payload: StageCreate, tenant_id: int, company_id: int) -> PipelineStage:
|
||||
# La etapa debe pertenecer a un embudo del mismo tenant/company
|
||||
get_pipeline(db, payload.pipeline_id, tenant_id, company_id)
|
||||
stage = PipelineStage(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id)
|
||||
db.add(stage)
|
||||
db.commit()
|
||||
db.refresh(stage)
|
||||
return stage
|
||||
|
||||
|
||||
def update_stage(
|
||||
db: Session, stage_id: int, payload: StageUpdate, tenant_id: int, company_id: int
|
||||
) -> PipelineStage:
|
||||
stage = get_stage(db, stage_id, tenant_id, company_id)
|
||||
for field, value in payload.model_dump(exclude_unset=True).items():
|
||||
setattr(stage, field, value)
|
||||
db.commit()
|
||||
db.refresh(stage)
|
||||
return stage
|
||||
|
||||
|
||||
def delete_stage(db: Session, stage_id: int, tenant_id: int, company_id: int) -> None:
|
||||
stage = get_stage(db, stage_id, tenant_id, company_id)
|
||||
stage.deleted_at = datetime.now(timezone.utc)
|
||||
db.commit()
|
||||
27
backend/api/v1/modules/crm/router.py
Normal file
27
backend/api/v1/modules/crm/router.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Router agregador del módulo CRM.
|
||||
|
||||
Se monta bajo el prefijo ``/crm`` en ``api/v1/router.py``.
|
||||
Importar este módulo también registra los permisos del CRM (side-effect de
|
||||
``permissions``), siguiendo el patrón del ``PermissionRegistry``.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from . import permissions # noqa: F401 (side-effect: registra permisos del CRM)
|
||||
from .accounts.routes import router as accounts_router
|
||||
from .activities.routes import router as activities_router
|
||||
from .contacts.routes import router as contacts_router
|
||||
from .leads.routes import router as leads_router
|
||||
from .metrics.routes import router as metrics_router
|
||||
from .opportunities.routes import router as opportunities_router
|
||||
from .pipelines.routes import router as pipelines_router
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(accounts_router)
|
||||
router.include_router(contacts_router)
|
||||
router.include_router(leads_router)
|
||||
router.include_router(pipelines_router)
|
||||
router.include_router(opportunities_router)
|
||||
router.include_router(activities_router)
|
||||
router.include_router(metrics_router)
|
||||
@@ -5,12 +5,14 @@ Router principal de API v1
|
||||
from fastapi import APIRouter
|
||||
|
||||
from .modules.core.router import router as core_router
|
||||
from .modules.crm.router import router as crm_router
|
||||
from .modules.example.routes import router as example_router
|
||||
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
router.include_router(core_router)
|
||||
router.include_router(crm_router, prefix="/crm", tags=["crm"])
|
||||
router.include_router(example_router, prefix="/example", tags=["example"])
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user