Files
GTI_GERENCIADOR_FISCAL/central/app/client_sync.py
T
gtiandClaude Sonnet 5 e7d77beb26 feat: GTI Gerenciador as the single source of truth for clients; bump to 1.1.2
Tenant gains source (LOCAL|GTI_GERENCIADOR), external_client_id, external_code,
active and synced_at (migration 0024). central/app/client_sync.py fetches from
the panel's new GET /internal/fiscal/clients (dedicated bearer token, never
MySQL credentials) and upserts Tenant rows, never touching a LOCAL row.
/agents/new now offers only active GTI_GERENCIADOR tenants, with a client-side
search box; the POST validates the same rule server-side, not just in the
dropdown. central/scripts/sync_clients_from_gerenciador.py is the operator-run
sync + reconciliation report.

GTI_FISCAL_VERSION/AGENT_VERSION bumped to 1.1.2 (release/VERSION single
source of truth), incorporating the installation_id persistence fix. Rebuilt
the EXE installer trio at 1.1.2 (scripts/build_windows_installer.ps1).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-23 22:38:58 -03:00

104 lines
3.9 KiB
Python

"""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)