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:
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
Reference in New Issue
Block a user