- Nuevo schema `crm` con 7 tablas multi-tenant (TenantScopedMixin + soft delete) - Módulos FastAPI por dominio: models/dto/service/routes (patrón example) - Métricas del dashboard (KPIs + embudo por etapa) - Conversión de prospecto → cuenta/contacto/oportunidad (idempotente) - Movimiento de oportunidad entre etapas (Kanban) con estado/probabilidad derivados - 25 permisos registrados en PermissionRegistry - Migración Alembic con upgrade/downgrade completos - 24 tests de servicios (pytest) en verde Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
134 lines
4.4 KiB
Python
134 lines
4.4 KiB
Python
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()
|