diff --git a/backend/alembic/versions/f1a2b3c4d5e6_crm_schema.py b/backend/alembic/versions/f1a2b3c4d5e6_crm_schema.py new file mode 100644 index 0000000..041a2b6 --- /dev/null +++ b/backend/alembic/versions/f1a2b3c4d5e6_crm_schema.py @@ -0,0 +1,237 @@ +"""crm schema (accounts, contacts, leads, pipelines, opportunities, activities) + +Revision ID: f1a2b3c4d5e6 +Revises: g2h3i4j5k6l7 +Create Date: 2026-07-14 00:00:00.000000 + +""" +from typing import Sequence, Union + +import sqlalchemy as sa +from alembic import op + +revision: str = "f1a2b3c4d5e6" +down_revision: Union[str, None] = "g2h3i4j5k6l7" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + +SCHEMA = "crm" + + +def _scoped_columns() -> list[sa.Column]: + """Columnas comunes de TenantScopedMixin + TimestampMixin.""" + return [ + sa.Column("tenant_id", sa.Integer(), nullable=False), + sa.Column("company_id", sa.Integer(), nullable=False), + sa.Column("created_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("updated_at", sa.DateTime(), nullable=False, server_default=sa.text("now()")), + sa.Column("deleted_at", sa.DateTime(), nullable=True), + ] + + +def _scoped_indexes(table: str) -> None: + op.create_index(f"ix_{SCHEMA}_{table}_id", table, ["id"], schema=SCHEMA) + op.create_index(f"ix_{SCHEMA}_{table}_tenant_id", table, ["tenant_id"], schema=SCHEMA) + op.create_index(f"ix_{SCHEMA}_{table}_company_id", table, ["company_id"], schema=SCHEMA) + + +def upgrade() -> None: + op.execute(f"CREATE SCHEMA IF NOT EXISTS {SCHEMA}") + + # ----- pipelines ----- + op.create_table( + "pipelines", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("is_default", sa.Boolean(), nullable=False, server_default=sa.text("false")), + *_scoped_columns(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + schema=SCHEMA, + ) + _scoped_indexes("pipelines") + + # ----- pipeline_stages ----- + op.create_table( + "pipeline_stages", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("pipeline_id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=120), nullable=False), + sa.Column("position", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("probability", sa.Integer(), nullable=False, server_default=sa.text("0")), + sa.Column("is_won", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("is_lost", sa.Boolean(), nullable=False, server_default=sa.text("false")), + *_scoped_columns(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["pipeline_id"], [f"{SCHEMA}.pipelines.id"]), + schema=SCHEMA, + ) + _scoped_indexes("pipeline_stages") + op.create_index("ix_crm_pipeline_stages_pipeline_id", "pipeline_stages", ["pipeline_id"], schema=SCHEMA) + + # ----- accounts ----- + op.create_table( + "accounts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("trade_name", sa.String(length=255), nullable=True), + sa.Column("rfc", sa.String(length=13), nullable=True), + sa.Column("account_type", sa.String(length=40), nullable=True), + sa.Column("industry", sa.String(length=120), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=40), nullable=True), + sa.Column("website", sa.String(length=255), nullable=True), + sa.Column("address", sa.Text(), nullable=True), + sa.Column("city", sa.String(length=120), nullable=True), + sa.Column("state", sa.String(length=120), nullable=True), + sa.Column("country", sa.String(length=2), nullable=True, server_default=sa.text("'MX'")), + sa.Column("patente_aduanal", sa.String(length=20), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'active'")), + sa.Column("owner_user_id", sa.String(length=64), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + *_scoped_columns(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + schema=SCHEMA, + ) + _scoped_indexes("accounts") + op.create_index("ix_crm_accounts_rfc", "accounts", ["rfc"], schema=SCHEMA) + op.create_index("ix_crm_accounts_owner_user_id", "accounts", ["owner_user_id"], schema=SCHEMA) + + # ----- contacts ----- + op.create_table( + "contacts", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column("first_name", sa.String(length=120), nullable=False), + sa.Column("last_name", sa.String(length=120), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=40), nullable=True), + sa.Column("mobile", sa.String(length=40), nullable=True), + sa.Column("job_title", sa.String(length=120), nullable=True), + sa.Column("department", sa.String(length=120), nullable=True), + sa.Column("is_primary", sa.Boolean(), nullable=False, server_default=sa.text("false")), + sa.Column("owner_user_id", sa.String(length=64), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + *_scoped_columns(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["account_id"], [f"{SCHEMA}.accounts.id"]), + schema=SCHEMA, + ) + _scoped_indexes("contacts") + op.create_index("ix_crm_contacts_account_id", "contacts", ["account_id"], schema=SCHEMA) + op.create_index("ix_crm_contacts_email", "contacts", ["email"], schema=SCHEMA) + op.create_index("ix_crm_contacts_owner_user_id", "contacts", ["owner_user_id"], schema=SCHEMA) + + # ----- opportunities ----- + op.create_table( + "opportunities", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column("contact_id", sa.Integer(), nullable=True), + sa.Column("pipeline_id", sa.Integer(), nullable=True), + sa.Column("stage_id", sa.Integer(), nullable=True), + sa.Column("amount", sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column("currency", sa.String(length=3), nullable=False, server_default=sa.text("'MXN'")), + sa.Column("probability", sa.Integer(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'open'")), + sa.Column("expected_close_date", sa.Date(), nullable=True), + sa.Column("closed_at", sa.DateTime(), nullable=True), + sa.Column("lost_reason", sa.String(length=255), nullable=True), + sa.Column("source", sa.String(length=60), nullable=True), + sa.Column("owner_user_id", sa.String(length=64), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + *_scoped_columns(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["account_id"], [f"{SCHEMA}.accounts.id"]), + sa.ForeignKeyConstraint(["contact_id"], [f"{SCHEMA}.contacts.id"]), + sa.ForeignKeyConstraint(["pipeline_id"], [f"{SCHEMA}.pipelines.id"]), + sa.ForeignKeyConstraint(["stage_id"], [f"{SCHEMA}.pipeline_stages.id"]), + schema=SCHEMA, + ) + _scoped_indexes("opportunities") + op.create_index("ix_crm_opportunities_account_id", "opportunities", ["account_id"], schema=SCHEMA) + op.create_index("ix_crm_opportunities_contact_id", "opportunities", ["contact_id"], schema=SCHEMA) + op.create_index("ix_crm_opportunities_pipeline_id", "opportunities", ["pipeline_id"], schema=SCHEMA) + op.create_index("ix_crm_opportunities_stage_id", "opportunities", ["stage_id"], schema=SCHEMA) + op.create_index("ix_crm_opportunities_status", "opportunities", ["status"], schema=SCHEMA) + op.create_index("ix_crm_opportunities_owner_user_id", "opportunities", ["owner_user_id"], schema=SCHEMA) + + # ----- leads ----- + op.create_table( + "leads", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("name", sa.String(length=255), nullable=False), + sa.Column("contact_name", sa.String(length=160), nullable=True), + sa.Column("email", sa.String(length=255), nullable=True), + sa.Column("phone", sa.String(length=40), nullable=True), + sa.Column("company_name", sa.String(length=255), nullable=True), + sa.Column("source", sa.String(length=60), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'new'")), + sa.Column("estimated_value", sa.Numeric(precision=14, scale=2), nullable=True), + sa.Column("owner_user_id", sa.String(length=64), nullable=True), + sa.Column("converted_account_id", sa.Integer(), nullable=True), + sa.Column("converted_contact_id", sa.Integer(), nullable=True), + sa.Column("converted_opportunity_id", sa.Integer(), nullable=True), + sa.Column("notes", sa.Text(), nullable=True), + *_scoped_columns(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["converted_account_id"], [f"{SCHEMA}.accounts.id"]), + sa.ForeignKeyConstraint(["converted_contact_id"], [f"{SCHEMA}.contacts.id"]), + sa.ForeignKeyConstraint(["converted_opportunity_id"], [f"{SCHEMA}.opportunities.id"]), + schema=SCHEMA, + ) + _scoped_indexes("leads") + op.create_index("ix_crm_leads_email", "leads", ["email"], schema=SCHEMA) + op.create_index("ix_crm_leads_status", "leads", ["status"], schema=SCHEMA) + op.create_index("ix_crm_leads_owner_user_id", "leads", ["owner_user_id"], schema=SCHEMA) + + # ----- activities ----- + op.create_table( + "activities", + sa.Column("id", sa.Integer(), nullable=False), + sa.Column("activity_type", sa.String(length=20), nullable=False), + sa.Column("subject", sa.String(length=255), nullable=False), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("status", sa.String(length=20), nullable=False, server_default=sa.text("'pending'")), + sa.Column("due_date", sa.DateTime(), nullable=True), + sa.Column("completed_at", sa.DateTime(), nullable=True), + sa.Column("account_id", sa.Integer(), nullable=True), + sa.Column("contact_id", sa.Integer(), nullable=True), + sa.Column("lead_id", sa.Integer(), nullable=True), + sa.Column("opportunity_id", sa.Integer(), nullable=True), + sa.Column("owner_user_id", sa.String(length=64), nullable=True), + *_scoped_columns(), + sa.PrimaryKeyConstraint("id"), + sa.ForeignKeyConstraint(["tenant_id"], ["core.tenants.id"]), + sa.ForeignKeyConstraint(["account_id"], [f"{SCHEMA}.accounts.id"]), + sa.ForeignKeyConstraint(["contact_id"], [f"{SCHEMA}.contacts.id"]), + sa.ForeignKeyConstraint(["lead_id"], [f"{SCHEMA}.leads.id"]), + sa.ForeignKeyConstraint(["opportunity_id"], [f"{SCHEMA}.opportunities.id"]), + schema=SCHEMA, + ) + _scoped_indexes("activities") + op.create_index("ix_crm_activities_status", "activities", ["status"], schema=SCHEMA) + op.create_index("ix_crm_activities_account_id", "activities", ["account_id"], schema=SCHEMA) + op.create_index("ix_crm_activities_contact_id", "activities", ["contact_id"], schema=SCHEMA) + op.create_index("ix_crm_activities_lead_id", "activities", ["lead_id"], schema=SCHEMA) + op.create_index("ix_crm_activities_opportunity_id", "activities", ["opportunity_id"], schema=SCHEMA) + op.create_index("ix_crm_activities_owner_user_id", "activities", ["owner_user_id"], schema=SCHEMA) + + +def downgrade() -> None: + # Orden inverso al de creación para respetar las FK. + # DROP TABLE elimina automáticamente los índices asociados en PostgreSQL. + op.drop_table("activities", schema=SCHEMA) + op.drop_table("leads", schema=SCHEMA) + op.drop_table("opportunities", schema=SCHEMA) + op.drop_table("contacts", schema=SCHEMA) + op.drop_table("accounts", schema=SCHEMA) + op.drop_table("pipeline_stages", schema=SCHEMA) + op.drop_table("pipelines", schema=SCHEMA) + op.execute(f"DROP SCHEMA IF EXISTS {SCHEMA}") diff --git a/backend/api/v1/modules/crm/__init__.py b/backend/api/v1/modules/crm/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/accounts/__init__.py b/backend/api/v1/modules/crm/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/accounts/dto.py b/backend/api/v1/modules/crm/accounts/dto.py new file mode 100644 index 0000000..fda82a2 --- /dev/null +++ b/backend/api/v1/modules/crm/accounts/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/accounts/models.py b/backend/api/v1/modules/crm/accounts/models.py new file mode 100644 index 0000000..2f6b0a5 --- /dev/null +++ b/backend/api/v1/modules/crm/accounts/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/accounts/routes.py b/backend/api/v1/modules/crm/accounts/routes.py new file mode 100644 index 0000000..08105ef --- /dev/null +++ b/backend/api/v1/modules/crm/accounts/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/accounts/service.py b/backend/api/v1/modules/crm/accounts/service.py new file mode 100644 index 0000000..79efc0e --- /dev/null +++ b/backend/api/v1/modules/crm/accounts/service.py @@ -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() diff --git a/backend/api/v1/modules/crm/activities/__init__.py b/backend/api/v1/modules/crm/activities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/activities/dto.py b/backend/api/v1/modules/crm/activities/dto.py new file mode 100644 index 0000000..2a916f4 --- /dev/null +++ b/backend/api/v1/modules/crm/activities/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/activities/models.py b/backend/api/v1/modules/crm/activities/models.py new file mode 100644 index 0000000..65c624b --- /dev/null +++ b/backend/api/v1/modules/crm/activities/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/activities/routes.py b/backend/api/v1/modules/crm/activities/routes.py new file mode 100644 index 0000000..4f778dd --- /dev/null +++ b/backend/api/v1/modules/crm/activities/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/activities/service.py b/backend/api/v1/modules/crm/activities/service.py new file mode 100644 index 0000000..0f772cf --- /dev/null +++ b/backend/api/v1/modules/crm/activities/service.py @@ -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() diff --git a/backend/api/v1/modules/crm/contacts/__init__.py b/backend/api/v1/modules/crm/contacts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/contacts/dto.py b/backend/api/v1/modules/crm/contacts/dto.py new file mode 100644 index 0000000..9855a8c --- /dev/null +++ b/backend/api/v1/modules/crm/contacts/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/contacts/models.py b/backend/api/v1/modules/crm/contacts/models.py new file mode 100644 index 0000000..a04f0cf --- /dev/null +++ b/backend/api/v1/modules/crm/contacts/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/contacts/routes.py b/backend/api/v1/modules/crm/contacts/routes.py new file mode 100644 index 0000000..7362bc1 --- /dev/null +++ b/backend/api/v1/modules/crm/contacts/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/contacts/service.py b/backend/api/v1/modules/crm/contacts/service.py new file mode 100644 index 0000000..5089fec --- /dev/null +++ b/backend/api/v1/modules/crm/contacts/service.py @@ -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() diff --git a/backend/api/v1/modules/crm/leads/__init__.py b/backend/api/v1/modules/crm/leads/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/leads/dto.py b/backend/api/v1/modules/crm/leads/dto.py new file mode 100644 index 0000000..904dd47 --- /dev/null +++ b/backend/api/v1/modules/crm/leads/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/leads/models.py b/backend/api/v1/modules/crm/leads/models.py new file mode 100644 index 0000000..a1e4140 --- /dev/null +++ b/backend/api/v1/modules/crm/leads/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/leads/routes.py b/backend/api/v1/modules/crm/leads/routes.py new file mode 100644 index 0000000..68fb486 --- /dev/null +++ b/backend/api/v1/modules/crm/leads/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/leads/service.py b/backend/api/v1/modules/crm/leads/service.py new file mode 100644 index 0000000..3763f53 --- /dev/null +++ b/backend/api/v1/modules/crm/leads/service.py @@ -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, + } diff --git a/backend/api/v1/modules/crm/metrics/__init__.py b/backend/api/v1/modules/crm/metrics/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/metrics/dto.py b/backend/api/v1/modules/crm/metrics/dto.py new file mode 100644 index 0000000..7d1eb33 --- /dev/null +++ b/backend/api/v1/modules/crm/metrics/dto.py @@ -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] diff --git a/backend/api/v1/modules/crm/metrics/routes.py b/backend/api/v1/modules/crm/metrics/routes.py new file mode 100644 index 0000000..c6e02d6 --- /dev/null +++ b/backend/api/v1/modules/crm/metrics/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/metrics/service.py b/backend/api/v1/modules/crm/metrics/service.py new file mode 100644 index 0000000..82e9802 --- /dev/null +++ b/backend/api/v1/modules/crm/metrics/service.py @@ -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, + } diff --git a/backend/api/v1/modules/crm/opportunities/__init__.py b/backend/api/v1/modules/crm/opportunities/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/opportunities/dto.py b/backend/api/v1/modules/crm/opportunities/dto.py new file mode 100644 index 0000000..24e0620 --- /dev/null +++ b/backend/api/v1/modules/crm/opportunities/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/opportunities/models.py b/backend/api/v1/modules/crm/opportunities/models.py new file mode 100644 index 0000000..d9939b1 --- /dev/null +++ b/backend/api/v1/modules/crm/opportunities/models.py @@ -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) diff --git a/backend/api/v1/modules/crm/opportunities/routes.py b/backend/api/v1/modules/crm/opportunities/routes.py new file mode 100644 index 0000000..ab75ac7 --- /dev/null +++ b/backend/api/v1/modules/crm/opportunities/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/opportunities/service.py b/backend/api/v1/modules/crm/opportunities/service.py new file mode 100644 index 0000000..299f2b5 --- /dev/null +++ b/backend/api/v1/modules/crm/opportunities/service.py @@ -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() diff --git a/backend/api/v1/modules/crm/permissions.py b/backend/api/v1/modules/crm/permissions.py new file mode 100644 index 0000000..4a930df --- /dev/null +++ b/backend/api/v1/modules/crm/permissions.py @@ -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() diff --git a/backend/api/v1/modules/crm/pipelines/__init__.py b/backend/api/v1/modules/crm/pipelines/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/api/v1/modules/crm/pipelines/dto.py b/backend/api/v1/modules/crm/pipelines/dto.py new file mode 100644 index 0000000..33a3576 --- /dev/null +++ b/backend/api/v1/modules/crm/pipelines/dto.py @@ -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 diff --git a/backend/api/v1/modules/crm/pipelines/models.py b/backend/api/v1/modules/crm/pipelines/models.py new file mode 100644 index 0000000..bb094d8 --- /dev/null +++ b/backend/api/v1/modules/crm/pipelines/models.py @@ -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")) diff --git a/backend/api/v1/modules/crm/pipelines/routes.py b/backend/api/v1/modules/crm/pipelines/routes.py new file mode 100644 index 0000000..2129ef0 --- /dev/null +++ b/backend/api/v1/modules/crm/pipelines/routes.py @@ -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) diff --git a/backend/api/v1/modules/crm/pipelines/service.py b/backend/api/v1/modules/crm/pipelines/service.py new file mode 100644 index 0000000..4230b64 --- /dev/null +++ b/backend/api/v1/modules/crm/pipelines/service.py @@ -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() diff --git a/backend/api/v1/modules/crm/router.py b/backend/api/v1/modules/crm/router.py new file mode 100644 index 0000000..c4e34e4 --- /dev/null +++ b/backend/api/v1/modules/crm/router.py @@ -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) diff --git a/backend/api/v1/router.py b/backend/api/v1/router.py index 3571b0a..687009c 100644 --- a/backend/api/v1/router.py +++ b/backend/api/v1/router.py @@ -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"]) diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..244f2fc --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,75 @@ +"""Fixtures de pruebas del módulo CRM. + +Las pruebas de servicios corren contra SQLite en memoria usando +``schema_translate_map`` para mapear los schemas ``crm``/``core`` al schema +principal de SQLite. Esto permite ejercitar la lógica de negocio sin depender +de PostgreSQL/Docker en el entorno de desarrollo local. + +Nota: la validación estructural del esquema (FKs cross-schema, DDL) se hace +con la migración Alembic contra PostgreSQL en CI (``TEST_DATABASE_URL``). +""" + +import os +import sys +from datetime import datetime + +import pytest +from sqlalchemy import Column, Integer, String, Table, create_engine, event +from sqlalchemy.orm import sessionmaker +from sqlalchemy.pool import StaticPool + +BACKEND_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if BACKEND_DIR not in sys.path: + sys.path.insert(0, BACKEND_DIR) + +from core.database import Base # noqa: E402 + +# Importar los modelos registra sus tablas en Base.metadata +import api.v1.modules.crm.accounts.models # noqa: E402,F401 +import api.v1.modules.crm.activities.models # noqa: E402,F401 +import api.v1.modules.crm.contacts.models # noqa: E402,F401 +import api.v1.modules.crm.leads.models # noqa: E402,F401 +import api.v1.modules.crm.opportunities.models # noqa: E402,F401 +import api.v1.modules.crm.pipelines.models # noqa: E402,F401 + +_SCHEMA_MAP = {"crm": None, "core": None} + +# Tabla mínima core.tenants para resolver la FK tenant_id de las tablas crm. +# En CI (PostgreSQL) la tabla real la crea la migración inicial del core. +if "core.tenants" not in Base.metadata.tables: + Table( + "tenants", + Base.metadata, + Column("id", Integer, primary_key=True), + Column("name", String(255)), + schema="core", + ) + +TENANT_ID = 1 +COMPANY_ID = 1 + + +@pytest.fixture() +def db(): + engine = create_engine( + "sqlite://", + connect_args={"check_same_thread": False}, + poolclass=StaticPool, + future=True, + ).execution_options(schema_translate_map=_SCHEMA_MAP) + + @event.listens_for(engine, "connect") + def _register_now(dbapi_conn, _record): + # Soporta server_default text("now()") de los mixins de timestamp + dbapi_conn.create_function( + "now", 0, lambda: datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S.%f") + ) + + Base.metadata.create_all(engine) + session_factory = sessionmaker(bind=engine, future=True) + session = session_factory() + try: + yield session + finally: + session.close() + engine.dispose() diff --git a/backend/tests/test_accounts.py b/backend/tests/test_accounts.py new file mode 100644 index 0000000..d91fc91 --- /dev/null +++ b/backend/tests/test_accounts.py @@ -0,0 +1,59 @@ +import pytest +from fastapi import HTTPException + +from api.v1.modules.crm.accounts import service +from api.v1.modules.crm.accounts.dto import AccountCreate, AccountUpdate + +T, C = 1, 1 + + +def test_create_and_get_account(db): + acc = service.create_account( + db, + AccountCreate(name="Importadora Demo", rfc="XAXX010101000", account_type="importador"), + T, C, + ) + assert acc.id is not None + assert acc.status == "active" + assert acc.country == "MX" + got = service.get_account(db, acc.id, T, C) + assert got.name == "Importadora Demo" + assert got.rfc == "XAXX010101000" + + +def test_list_and_search(db): + service.create_account(db, AccountCreate(name="Alpha SA"), T, C) + service.create_account(db, AccountCreate(name="Beta SA"), T, C) + assert len(service.get_accounts(db, T, C)) == 2 + found = service.get_accounts(db, T, C, search="alpha") + assert len(found) == 1 and found[0].name == "Alpha SA" + + +def test_filter_by_status(db): + service.create_account(db, AccountCreate(name="Activa", status="active"), T, C) + service.create_account(db, AccountCreate(name="Prospecto", status="prospect"), T, C) + only_prospect = service.get_accounts(db, T, C, account_status="prospect") + assert len(only_prospect) == 1 and only_prospect[0].name == "Prospecto" + + +def test_update_account(db): + acc = service.create_account(db, AccountCreate(name="X"), T, C) + upd = service.update_account(db, acc.id, AccountUpdate(status="inactive", phone="5551234567"), T, C) + assert upd.status == "inactive" + assert upd.phone == "5551234567" + + +def test_soft_delete_hides_account(db): + acc = service.create_account(db, AccountCreate(name="Y"), T, C) + service.delete_account(db, acc.id, T, C) + with pytest.raises(HTTPException) as exc: + service.get_account(db, acc.id, T, C) + assert exc.value.status_code == 404 + assert service.get_accounts(db, T, C) == [] + + +def test_tenant_isolation(db): + acc = service.create_account(db, AccountCreate(name="Z"), T, C) + assert service.get_accounts(db, tenant_id=999, company_id=C) == [] + with pytest.raises(HTTPException): + service.get_account(db, acc.id, 999, C) diff --git a/backend/tests/test_activities.py b/backend/tests/test_activities.py new file mode 100644 index 0000000..347c001 --- /dev/null +++ b/backend/tests/test_activities.py @@ -0,0 +1,38 @@ +import pytest +from fastapi import HTTPException + +from api.v1.modules.crm.activities import service +from api.v1.modules.crm.activities.dto import ActivityCreate + +T, C = 1, 1 + + +def test_create_rejects_invalid_type(db): + with pytest.raises(HTTPException) as exc: + service.create_activity(db, ActivityCreate(activity_type="invalido", subject="x"), T, C) + assert exc.value.status_code == 422 + + +def test_complete_activity_sets_timestamp(db): + activity = service.create_activity(db, ActivityCreate(activity_type="call", subject="Llamar al cliente"), T, C) + assert activity.status == "pending" + done = service.complete_activity(db, activity.id, T, C) + assert done.status == "completed" + assert done.completed_at is not None + + +def test_create_rejects_unknown_related_entity(db): + with pytest.raises(HTTPException) as exc: + service.create_activity( + db, ActivityCreate(activity_type="task", subject="x", opportunity_id=999), T, C + ) + assert exc.value.status_code == 422 + + +def test_filter_by_type_and_status(db): + service.create_activity(db, ActivityCreate(activity_type="call", subject="a"), T, C) + service.create_activity(db, ActivityCreate(activity_type="meeting", subject="b"), T, C) + calls = service.get_activities(db, T, C, activity_type="call") + assert len(calls) == 1 and calls[0].activity_type == "call" + pending = service.get_activities(db, T, C, activity_status="pending") + assert len(pending) == 2 diff --git a/backend/tests/test_contacts.py b/backend/tests/test_contacts.py new file mode 100644 index 0000000..f1613da --- /dev/null +++ b/backend/tests/test_contacts.py @@ -0,0 +1,33 @@ +import pytest +from fastapi import HTTPException + +from api.v1.modules.crm.accounts import service as accounts_service +from api.v1.modules.crm.accounts.dto import AccountCreate +from api.v1.modules.crm.contacts import service +from api.v1.modules.crm.contacts.dto import ContactCreate + +T, C = 1, 1 + + +def test_create_contact_rejects_unknown_account(db): + with pytest.raises(HTTPException) as exc: + service.create_contact(db, ContactCreate(first_name="Juan", account_id=999), T, C) + assert exc.value.status_code == 422 + + +def test_create_and_list_by_account(db): + acc = accounts_service.create_account(db, AccountCreate(name="Empresa"), T, C) + contact = service.create_contact( + db, + ContactCreate(first_name="Ana", last_name="López", account_id=acc.id, is_primary=True), + T, C, + ) + assert contact.account_id == acc.id + assert contact.is_primary is True + listed = service.get_contacts(db, T, C, account_id=acc.id) + assert len(listed) == 1 and listed[0].first_name == "Ana" + + +def test_contact_without_account_is_allowed(db): + contact = service.create_contact(db, ContactCreate(first_name="Suelto"), T, C) + assert contact.account_id is None diff --git a/backend/tests/test_leads.py b/backend/tests/test_leads.py new file mode 100644 index 0000000..dad9c7d --- /dev/null +++ b/backend/tests/test_leads.py @@ -0,0 +1,55 @@ +from api.v1.modules.crm.leads import service +from api.v1.modules.crm.leads.dto import LeadConvert, LeadCreate +from api.v1.modules.crm.pipelines import service as pipelines_service +from api.v1.modules.crm.pipelines.dto import PipelineCreate, StageCreate + +T, C = 1, 1 + + +def test_create_lead_defaults_to_new(db): + lead = service.create_lead(db, LeadCreate(name="Prospecto X", company_name="XYZ SA"), T, C) + assert lead.status == "new" + + +def test_convert_lead_creates_account_contact_opportunity(db): + pipeline = pipelines_service.create_pipeline(db, PipelineCreate(name="Ventas", is_default=True), T, C) + stage = pipelines_service.create_stage(db, StageCreate(pipeline_id=pipeline.id, name="Prospecto"), T, C) + + lead = service.create_lead( + db, + LeadCreate( + name="Oportunidad IMMEX", + company_name="Maquiladora del Norte", + contact_name="María Pérez", + email="maria@example.com", + estimated_value=50000, + ), + T, C, + ) + result = service.convert_lead( + db, lead.id, LeadConvert(create_opportunity=True, pipeline_id=pipeline.id, stage_id=stage.id), T, C + ) + + assert result["account_id"] is not None + assert result["contact_id"] is not None + assert result["opportunity_id"] is not None + assert result["lead"].status == "converted" + assert result["lead"].converted_account_id == result["account_id"] + + +def test_convert_is_idempotent(db): + lead = service.create_lead(db, LeadCreate(name="P", company_name="Empresa"), T, C) + first = service.convert_lead(db, lead.id, LeadConvert(create_opportunity=False), T, C) + second = service.convert_lead(db, lead.id, LeadConvert(create_opportunity=False), T, C) + assert first["account_id"] == second["account_id"] + + +def test_convert_splits_contact_name(db): + lead = service.create_lead(db, LeadCreate(name="P", contact_name="Juan Carlos Ramírez"), T, C) + result = service.convert_lead(db, lead.id, LeadConvert(create_opportunity=False), T, C) + + from api.v1.modules.crm.contacts import service as contacts_service + + contact = contacts_service.get_contact(db, result["contact_id"], T, C) + assert contact.first_name == "Juan" + assert contact.last_name == "Carlos Ramírez" diff --git a/backend/tests/test_metrics.py b/backend/tests/test_metrics.py new file mode 100644 index 0000000..fb55d52 --- /dev/null +++ b/backend/tests/test_metrics.py @@ -0,0 +1,42 @@ +from api.v1.modules.crm.accounts import service as accounts_service +from api.v1.modules.crm.accounts.dto import AccountCreate +from api.v1.modules.crm.metrics import service as metrics_service +from api.v1.modules.crm.opportunities import service as opportunities_service +from api.v1.modules.crm.opportunities.dto import OpportunityCreate +from api.v1.modules.crm.pipelines import service as pipelines_service +from api.v1.modules.crm.pipelines.dto import PipelineCreate, StageCreate + +T, C = 1, 1 + + +def test_metrics_counts_and_pipeline(db): + accounts_service.create_account(db, AccountCreate(name="Cuenta 1"), T, C) + pipeline = pipelines_service.create_pipeline(db, PipelineCreate(name="Ventas", is_default=True), T, C) + s_open = pipelines_service.create_stage( + db, StageCreate(pipeline_id=pipeline.id, name="Prospecto", position=0, probability=20), T, C + ) + s_won = pipelines_service.create_stage( + db, StageCreate(pipeline_id=pipeline.id, name="Ganada", position=1, is_won=True), T, C + ) + + opportunities_service.create_opportunity( + db, OpportunityCreate(name="Abierta", pipeline_id=pipeline.id, stage_id=s_open.id, amount=1000), T, C + ) + won = opportunities_service.create_opportunity( + db, OpportunityCreate(name="Cerrada", pipeline_id=pipeline.id, stage_id=s_open.id, amount=2000), T, C + ) + opportunities_service.move_opportunity(db, won.id, s_won.id, T, C) + + metrics = metrics_service.get_metrics(db, T, C) + + assert metrics["total_accounts"] == 1 + assert metrics["open_opportunities"] == 1 + assert float(metrics["open_pipeline_value"]) == 1000.0 + assert metrics["won_opportunities"] == 1 + assert float(metrics["won_value"]) == 2000.0 + + # El embudo reporta ambas etapas; la abierta tiene 1 oportunidad + by_stage = {row["stage_name"]: row for row in metrics["by_stage"]} + assert by_stage["Prospecto"]["count"] == 1 + assert float(by_stage["Prospecto"]["value"]) == 1000.0 + assert by_stage["Ganada"]["count"] == 0 diff --git a/backend/tests/test_opportunities.py b/backend/tests/test_opportunities.py new file mode 100644 index 0000000..b294306 --- /dev/null +++ b/backend/tests/test_opportunities.py @@ -0,0 +1,77 @@ +import pytest +from fastapi import HTTPException + +from api.v1.modules.crm.opportunities import service +from api.v1.modules.crm.opportunities.dto import OpportunityCreate +from api.v1.modules.crm.pipelines import service as pipelines_service +from api.v1.modules.crm.pipelines.dto import PipelineCreate, StageCreate + +T, C = 1, 1 + + +def _pipeline_with_stages(db): + pipeline = pipelines_service.create_pipeline(db, PipelineCreate(name="Ventas", is_default=True), T, C) + s_open = pipelines_service.create_stage( + db, StageCreate(pipeline_id=pipeline.id, name="Prospecto", position=0, probability=10), T, C + ) + s_won = pipelines_service.create_stage( + db, StageCreate(pipeline_id=pipeline.id, name="Ganada", position=1, probability=100, is_won=True), T, C + ) + s_lost = pipelines_service.create_stage( + db, StageCreate(pipeline_id=pipeline.id, name="Perdida", position=2, is_lost=True), T, C + ) + return pipeline, s_open, s_won, s_lost + + +def test_create_opportunity(db): + pipeline, s_open, _, _ = _pipeline_with_stages(db) + opp = service.create_opportunity( + db, + OpportunityCreate(name="Licencia Aduanasoft", pipeline_id=pipeline.id, stage_id=s_open.id, amount=15000), + T, C, + ) + assert opp.status == "open" + assert opp.currency == "MXN" + + +def test_move_to_won_closes_and_sets_probability(db): + pipeline, s_open, s_won, _ = _pipeline_with_stages(db) + opp = service.create_opportunity(db, OpportunityCreate(name="Deal", pipeline_id=pipeline.id, stage_id=s_open.id), T, C) + moved = service.move_opportunity(db, opp.id, s_won.id, T, C) + assert moved.status == "won" + assert moved.probability == 100 + assert moved.closed_at is not None + assert moved.stage_id == s_won.id + + +def test_move_to_lost(db): + pipeline, s_open, _, s_lost = _pipeline_with_stages(db) + opp = service.create_opportunity(db, OpportunityCreate(name="Deal", pipeline_id=pipeline.id, stage_id=s_open.id), T, C) + moved = service.move_opportunity(db, opp.id, s_lost.id, T, C) + assert moved.status == "lost" + assert moved.probability == 0 + assert moved.closed_at is not None + + +def test_move_back_to_open_reopens(db): + pipeline, s_open, s_won, _ = _pipeline_with_stages(db) + opp = service.create_opportunity(db, OpportunityCreate(name="Deal", pipeline_id=pipeline.id, stage_id=s_open.id), T, C) + service.move_opportunity(db, opp.id, s_won.id, T, C) + reopened = service.move_opportunity(db, opp.id, s_open.id, T, C) + assert reopened.status == "open" + assert reopened.probability == 10 + assert reopened.closed_at is None + + +def test_create_rejects_unknown_account(db): + with pytest.raises(HTTPException) as exc: + service.create_opportunity(db, OpportunityCreate(name="X", account_id=999), T, C) + assert exc.value.status_code == 422 + + +def test_only_one_default_pipeline(db): + pipelines_service.create_pipeline(db, PipelineCreate(name="P1", is_default=True), T, C) + pipelines_service.create_pipeline(db, PipelineCreate(name="P2", is_default=True), T, C) + pipelines = pipelines_service.get_pipelines(db, T, C) + defaults = [p for p in pipelines if p.is_default] + assert len(defaults) == 1 and defaults[0].name == "P2"