feat(core,crm): auto-sync de tenants Workspace→CRM + mostrar tenant de la compañía activa

- assignable-tenants: para hub_admin, sincroniza automáticamente los tenants del
  Workspace (Hub GET /hub/tenants) a core.tenants con su mismo ID, y los devuelve.
  Así los tenants creados en el Workspace aparecen solos en el CRM para asignarles
  compañías (best-effort con el token KC de la sesión). CRM→Workspace ya lo hace
  Organizaciones (POST /hub/tenants).
- my-companies devuelve tenant_name/tenant_slug (join core.tenants).
- Switcher muestra el tenant de la compañía activa (antes "Sin tenant asignado").

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ernesto Herrera
2026-07-17 13:20:50 -06:00
parent 706da34f4a
commit 387b3c0e78
3 changed files with 91 additions and 10 deletions

View File

@@ -489,8 +489,9 @@ async def get_my_companies(
rows = db.execute( rows = db.execute(
text( text(
""" """
SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id SELECT c.id, c.name, c.rfc, c.logo, c.tenant_id, t.name, t.slug
FROM a76.company c FROM a76.company c
LEFT JOIN core.tenants t ON t.id = c.tenant_id
WHERE c.id IN ( WHERE c.id IN (
SELECT company_id FROM core.user_tenants SELECT company_id FROM core.user_tenants
WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL WHERE keycloak_user_id = :uid AND is_active AND company_id IS NOT NULL
@@ -509,6 +510,8 @@ async def get_my_companies(
"id": int(r[0]), "id": int(r[0]),
"name": r[1] or "Empresa", "name": r[1] or "Empresa",
"tenant_id": int(r[4]), "tenant_id": int(r[4]),
"tenant_name": r[5],
"tenant_slug": r[6],
"rfc": r[2], "rfc": r[2],
"logo": r[3], "logo": r[3],
"is_active": True, "is_active": True,
@@ -523,23 +526,90 @@ class _CreateCompanyDTO(BaseModel):
rfc: Optional[str] = None rfc: Optional[str] = None
async def _sync_tenants_from_hub(request: Request, db: Session) -> None:
"""
Auto-sync Workspace→CRM: trae los tenants del Workspace (Hub GET /hub/tenants) y
los da de alta/actualiza en core.tenants con su MISMO ID del Workspace. Así los
tenants creados en el Workspace aparecen solos en el CRM para asignarles compañías.
Best-effort: usa el token KC de la sesión (valkey); si no está fresco o el Hub no
responde, no bloquea (se devuelven los tenants ya sincronizados).
"""
import httpx
from sqlalchemy import text as _text
from core.config import settings
from core import session_store
from api.v1.modules.core.tenants.models import Tenant, TenantType
sid = request.cookies.get("crm_sid") if request else None
kc_token = None
if sid:
sess = session_store.get_session(sid)
kc_token = (sess or {}).get("access_token")
if not kc_token:
return
try:
async with httpx.AsyncClient(timeout=8.0) as client:
r = await client.get(
f"{settings.HUB_URL}api/v1/hub/tenants",
headers={"Authorization": f"Bearer {kc_token}"},
)
if r.status_code != 200:
logger.info("sync-tenants: Hub devolvió %s — sin sincronizar", r.status_code)
return
payload = r.json()
items = payload.get("tenants", []) if isinstance(payload, dict) else (payload or [])
for t in items:
tid = t.get("id")
if tid is None:
continue
name = t.get("name") or t.get("display_name") or t.get("slug")
slug = t.get("slug") or f"tenant-{tid}"
existing = db.query(Tenant).filter(Tenant.id == int(tid)).first()
if existing:
if name and existing.name != name:
existing.name = name
else:
db.add(Tenant(
id=int(tid), name=name or slug, slug=slug,
keycloak_realm=slug, type=TenantType.SHARED, is_active=True,
))
db.commit()
db.execute(_text("SELECT setval('core.tenants_id_seq', (SELECT MAX(id) FROM core.tenants))"))
db.commit()
except Exception as exc:
logger.warning("sync-tenants desde Hub falló (no bloquea): %s", exc)
try:
db.rollback()
except Exception:
pass
@router.get("/assignable-tenants") @router.get("/assignable-tenants")
async def assignable_tenants( async def assignable_tenants(
request: Request,
current_user: dict = Depends(get_current_user), current_user: dict = Depends(get_current_user),
db: Session = Depends(get_core_db), db: Session = Depends(get_core_db),
): ):
""" """
Tenants disponibles para asignar una compañía nueva. El tenant lo crea el Tenants disponibles para asignar una compañía. El tenant lo crea el Workspace;
Workspace; aquí solo se elige. hub_admin ve todos; un usuario con tenant ve el suyo. aquí solo se elige. hub_admin ve TODOS (auto-sincronizados del Hub); un usuario
con tenant ve el suyo.
""" """
from api.v1.modules.core.tenants.models import Tenant from api.v1.modules.core.tenants.models import Tenant
from core.security import resolve_effective_tenant_id_from_user, is_hub_admin from core.security import resolve_effective_tenant_id_from_user, is_hub_admin
q = db.query(Tenant).filter(Tenant.is_active == True) # noqa: E712 if is_hub_admin(current_user):
# Sincroniza automáticamente los tenants del Workspace antes de listar.
await _sync_tenants_from_hub(request, db)
rows = db.query(Tenant).filter(Tenant.is_active == True).order_by(Tenant.id).all() # noqa: E712
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in rows]
tid = resolve_effective_tenant_id_from_user(current_user) tid = resolve_effective_tenant_id_from_user(current_user)
if tid and not is_hub_admin(current_user): if tid:
q = q.filter(Tenant.id == int(tid)) t = db.query(Tenant).filter(Tenant.id == int(tid), Tenant.is_active == True).first() # noqa: E712
return [{"id": t.id, "name": t.name, "slug": t.slug} for t in q.order_by(Tenant.id).all()] return [{"id": t.id, "name": t.name, "slug": t.slug}] if t else []
return []
@router.post("/companies", status_code=201) @router.post("/companies", status_code=201)

View File

@@ -145,9 +145,18 @@
> >
<DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label> <DropdownMenu.Label class="text-xs text-muted-foreground">Tenant</DropdownMenu.Label>
{#if userTenants.length === 0} {#if userTenants.length === 0}
<DropdownMenu.Item disabled class="gap-2 p-2"> {#if companyStore.activeCompany?.tenant_name}
<span class="text-muted-foreground">Sin tenant asignado</span> <DropdownMenu.Item disabled class="gap-2 p-2">
</DropdownMenu.Item> <div class="flex size-6 items-center justify-center rounded-md border bg-muted">
<BuildingIcon class="size-3.5" />
</div>
<span class="truncate font-medium">{companyStore.activeCompany.tenant_name}</span>
</DropdownMenu.Item>
{:else}
<DropdownMenu.Item disabled class="gap-2 p-2">
<span class="text-muted-foreground">Sin tenant asignado</span>
</DropdownMenu.Item>
{/if}
{:else} {:else}
{#each userTenants as tenant (tenant.id)} {#each userTenants as tenant (tenant.id)}
<DropdownMenu.Item <DropdownMenu.Item

View File

@@ -11,6 +11,8 @@ interface Company {
rfc?: string; rfc?: string;
logo?: string; logo?: string;
tenant_id: number; tenant_id: number;
tenant_name?: string;
tenant_slug?: string;
} }
class CompanyStore { class CompanyStore {