Replaces the native list with a combobox (name/code/CNPJ search, accent- and case-insensitive, keyboard + mouse, custom dropdown, hidden client_id = the Gerenciador's real CLIENTE.ID). The backend resolves client_id against the active GTI_GERENCIADOR mirror and rejects free text/unknown ids. Adds Tenant.external_cnpj (migration 0025) mirrored via the internal endpoint. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
107 lines
4.1 KiB
Python
107 lines
4.1 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, external_cnpj=client.get("cnpj"), 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.external_cnpj != client.get("cnpj"):
|
|
row.external_cnpj = client.get("cnpj")
|
|
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)
|