"""Client mirror from GTI Gerenciador — the single source of truth for clients (central/app/models.py: Tenant.source/external_client_id/active). The Fiscal Hub never receives MySQL credentials; it calls the panel's read-only internal endpoint instead, authenticated by a dedicated bearer token (FISCAL_INTERNAL_API_TOKEN, shared only between the two apps' envs — never SESSION_SECRET, the SSO key, an admin password or an Agent token). Run `central/scripts/sync_clients_from_gerenciador.py` to pull a fresh copy; `Tenant.source='LOCAL'` rows (pre-existing dev/test tenants) are never touched or offered for a new installation. """ from __future__ import annotations from dataclasses import dataclass from datetime import datetime, timezone import httpx from sqlalchemy import select from . import config from .models import Tenant SOURCE = "GTI_GERENCIADOR" class ClientSyncUnavailable(Exception): pass def enabled() -> bool: return bool(config.settings.gerenciador_internal_url and config.settings.fiscal_internal_api_token) def fetch_from_gerenciador() -> list[dict]: if not enabled(): raise ClientSyncUnavailable("GERENCIADOR_INTERNAL_URL/FISCAL_INTERNAL_API_TOKEN not configured") headers = {"Authorization": f"Bearer {config.settings.fiscal_internal_api_token}"} try: response = httpx.get(config.settings.gerenciador_internal_url, headers=headers, timeout=config.settings.http_timeout_seconds) except httpx.HTTPError as error: raise ClientSyncUnavailable(f"request failed: {error}") from error if response.status_code != 200: raise ClientSyncUnavailable(f"unexpected status {response.status_code}: {response.text[:200]}") return response.json()["clients"] @dataclass class SyncResult: fetched: int created: int updated: int unchanged: int active: int inactive: int def sync_from_gerenciador(session, clients: list[dict]) -> SyncResult: """Upsert `clients` (each: external_client_id, code, display_name, active) into Tenant rows with source='GTI_GERENCIADOR'. Never touches a source='LOCAL' row. A display-name collision with another tenant is disambiguated with the external code rather than failing the whole sync over one client (Tenant.name is UNIQUE).""" now = datetime.now(timezone.utc) existing_by_external = {row.external_client_id: row for row in session.scalars(select(Tenant).where(Tenant.source == SOURCE)).all()} taken_names = {row.name for row in session.scalars(select(Tenant)).all()} created = updated = unchanged = 0 for client in clients: external_id = client["external_client_id"] code = client["code"] active = bool(client["active"]) wanted_name = (client.get("display_name") or "").strip() or f"Cliente {code}" row = existing_by_external.get(external_id) if row is None: name = wanted_name if wanted_name not in taken_names else f"{wanted_name} ({code})" row = Tenant(name=name, source=SOURCE, external_client_id=external_id, external_code=code, active=active, synced_at=now) session.add(row) taken_names.add(name) created += 1 continue changed = False if row.name != wanted_name and wanted_name not in taken_names: taken_names.discard(row.name) row.name = wanted_name taken_names.add(wanted_name) changed = True if row.external_code != code: row.external_code = code changed = True if row.active != active: row.active = active changed = True row.synced_at = now if changed: updated += 1 else: unchanged += 1 session.flush() active_count = sum(1 for c in clients if c["active"]) return SyncResult(fetched=len(clients), created=created, updated=updated, unchanged=unchanged, active=active_count, inactive=len(clients) - active_count)