feat(core,crm): gestión de compañías en el CRM + soporte hub_admin sin tenant

Las compañías (a76.company) se gestionan en el CRM, ligadas a un tenant del
Workspace. Cambios:

- my-companies: por MEMBRESÍA (user_tenants ∪ user_company_roles), así el hub_admin
  sin tenant en el token ve las compañías que creó/se le asignaron; para usuarios
  con tenant, autocrea una por defecto en el primer acceso.
- POST /auth/companies: da de alta una compañía bajo un tenant + asigna al usuario.
  GET /auth/assignable-tenants: tenants elegibles (hub_admin: todos; usuario: el suyo).
- security.validate_access_to_resource: si el token no trae tenant (hub_admin),
  resuelve el tenant desde la compañía activa (a76.company.tenant_id) → puede operar
  por compañía seleccionada.
- set-active: fija sso_tenant_id/pub con el tenant de la compañía (override).
- Pantalla "Compañías" (nav) para crear/listar/seleccionar.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-17 11:44:36 -06:00
parent 0b6848bbdd
commit 63ad2e2ecd
6 changed files with 335 additions and 59 deletions

View File

@@ -24,6 +24,12 @@ from .dto import (
)
from .service import AuthService
import logging
from typing import Optional
from pydantic import BaseModel
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/auth", tags=["Authentication"])
security = HTTPBearer()
@@ -432,60 +438,145 @@ async def get_my_companies(
)
from api.v1.modules.core.tenants.models import Tenant
from sqlalchemy import text
import logging as _logging
_log = _logging.getLogger(__name__)
user_id = current_user.get("sub") or current_user.get("id")
tenant_id = resolve_effective_tenant_id_from_user(current_user)
if not tenant_id:
# Usuario sin tenant (p. ej. hub_admin global): no hay compañía que resolver.
return []
tenant_id = int(tenant_id)
# La compañía es una entidad REAL del CRM (a76.company, heredada de Anexo76).
# Se listan las compañías del tenant; si no hay ninguna, se crea una por defecto
# en el primer acceso (nombre = el del tenant).
# 1) Usuario CON tenant en el token (flujo normal): autocrea una compañía por
# defecto en el primer acceso y asegura la membresía.
if tenant_id:
tenant_id = int(tenant_id)
exists = db.execute(
text("SELECT id FROM a76.company WHERE tenant_id = :tid ORDER BY id LIMIT 1"),
{"tid": tenant_id},
).fetchone()
if not exists:
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
default_name = (
(tenant.name if tenant else None)
or current_user.get("tenant_slug")
or "Mi empresa"
)
created = db.execute(
text("INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) RETURNING id"),
{"tid": tenant_id, "name": default_name},
).fetchone()
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
logger.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
if user_id:
try:
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, int(created[0]))
except Exception as exc:
logger.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
# 2) Compañías por MEMBRESÍA (user_tenants user_company_roles) → funciona
# también para hub_admin sin tenant en el token: verá las compañías que creó
# o a las que fue asignado. La membresía la determina el CRM, no el Hub.
if not user_id:
return []
rows = db.execute(
text("SELECT id, name, rfc, logo FROM a76.company WHERE tenant_id = :tid ORDER BY id"),
{"tid": tenant_id},
text(
"""
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id
FROM a76.company c
WHERE c.id IN (
SELECT company_id FROM core.user_tenants
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
UNION
SELECT company_id FROM core.user_company_roles
WHERE user_id = :uid AND is_active
)
ORDER BY c.id
"""
),
{"uid": str(user_id)},
).fetchall()
if not rows:
tenant = db.query(Tenant).filter(Tenant.id == tenant_id).first()
default_name = (
(tenant.name if tenant else None)
or current_user.get("tenant_slug")
or "Mi empresa"
)
created = db.execute(
text(
"INSERT INTO a76.company (tenant_id, name) VALUES (:tid, :name) "
"RETURNING id, name, rfc, logo"
),
{"tid": tenant_id, "name": default_name},
).fetchone()
# Alinear la secuencia por si hubo inserts con id explícito (seed dev).
db.execute(text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
rows = [created]
_log.info("Compañía por defecto creada para tenant=%s: id=%s", tenant_id, created[0])
# Asegurar el vínculo usuario↔compañía por cada compañía del tenant.
user_id = current_user.get("sub") or current_user.get("id")
companies = []
for r in rows:
cid = int(r[0])
if user_id:
try:
_ensure_user_tenant_for_company(db, str(user_id), tenant_id, cid)
except Exception as exc:
_log.warning("no se pudo asegurar user_tenant (no bloquea): %s", exc)
companies.append({
"id": cid,
"name": r[1] or "Mi empresa",
"tenant_id": tenant_id,
return [
{
"id": int(r[0]),
"name": r[1] or "Empresa",
"tenant_id": int(r[4]),
"rfc": r[2],
"logo": r[3],
"is_active": True,
})
return companies
}
for r in rows
]
class _CreateCompanyDTO(BaseModel):
name: str
tenant_id: int
rfc: Optional[str] = None
@router.get("/assignable-tenants")
async def assignable_tenants(
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Tenants disponibles para asignar una compañía nueva. El tenant lo crea el
Workspace; aquí solo se elige. hub_admin ve todos; un usuario con tenant ve el suyo.
"""
from api.v1.modules.core.tenants.models import Tenant
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
q = db.query(Tenant).filter(Tenant.is_active == True) # noqa: E712
tid = resolve_effective_tenant_id_from_user(current_user)
if tid and not is_hub_admin(current_user):
q = q.filter(Tenant.id == int(tid))
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in q.order_by(Tenant.id).all()]
@router.post("/companies", status_code=201)
async def create_company(
data: _CreateCompanyDTO,
current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db),
):
"""
Da de alta una compañía (a76.company) bajo un tenant del Workspace y asigna al
usuario como miembro. hub_admin puede crear en cualquier tenant; un usuario con
tenant solo en el suyo. El rol super_admin se otorga al seleccionarla (/permissions/me).
"""
from sqlalchemy import text as _text
from api.v1.modules.core.tenants.models import Tenant
from core.security import (
resolve_effective_tenant_id_from_user,
is_hub_admin,
_ensure_user_tenant_for_company,
)
name = (data.name or "").strip()
if len(name) < 2:
raise HTTPException(status_code=422, detail="El nombre de la compañía es obligatorio.")
tid = int(data.tenant_id)
tenant = db.query(Tenant).filter(Tenant.id == tid, Tenant.is_active == True).first() # noqa: E712
if not tenant:
raise HTTPException(status_code=404, detail="Tenant no encontrado.")
if not is_hub_admin(current_user):
own = resolve_effective_tenant_id_from_user(current_user)
if own is None or int(own) != tid:
raise HTTPException(status_code=403, detail="No puedes crear compañías en ese tenant.")
created = db.execute(
_text("INSERT INTO a76.company (tenant_id, name, rfc) VALUES (:t, :n, :r) RETURNING id"),
{"t": tid, "n": name, "r": (data.rfc or None)},
).fetchone()
db.execute(_text("SELECT setval('a76.company_id_seq', (SELECT MAX(id) FROM a76.company))"))
db.commit()
cid = int(created[0])
user_id = current_user.get("sub") or current_user.get("id")
if user_id:
try:
_ensure_user_tenant_for_company(db, str(user_id), tid, cid)
except Exception as exc:
logger.warning("create_company: no se pudo asegurar membresía (no bloquea): %s", exc)
return {"id": cid, "name": name, "tenant_id": tid, "rfc": data.rfc, "logo": None, "is_active": True}