diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..c3d2526 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,19 @@ +# Ambiente virtual / caches (nunca vao pra imagem) +.venv/ +__pycache__/ +*.pyc +.ruff_cache/ + +# Git +.git/ +.gitignore + +# SEGREDOS — nunca bakear na imagem (montar por volume em runtime) +configs.json +.env +certs/ + +# Ruido +logs/ +tests/ +*.md diff --git a/.gitignore b/.gitignore index 50331cb..5381105 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,10 @@ __pycache__/ .env logs/ configs.json -certs/ \ No newline at end of file +certs/ + +# Backups de config e chaves — contem segredo, nunca versionar +configs.json.* +*.bak-* +*.pem +*.key diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..400f6f2 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,31 @@ +# --------------------------------------------------------------------------- +# API - Scardua +# Imagem enxuta: thin mode do oracledb (sem Instant Client / sem libs nativas) +# --------------------------------------------------------------------------- +FROM python:3.12-slim + +# Nao gera .pyc e nao bufferiza o log (aparece na hora no docker logs) +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 + +WORKDIR /app + +# Dependencias primeiro para aproveitar o cache de camada do Docker +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Codigo da aplicacao +COPY main.py . +COPY app/ ./app/ + +EXPOSE 8000 + +# Healthcheck em exec-form (JSON, sem shell -> sem problema de aspas). +# O podman auto-update usa isto pra decidir rollback. +HEALTHCHECK --interval=30s --timeout=5s --start-period=20s --retries=3 \ + CMD ["python3", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health').read()"] + +# ATENCAO: config/segredos NAO entram na imagem (.dockerignore). +# No deploy sao injetados via Quadlet (Volume do configs.json) -> ver deploy/api-scardua.container. +# Roda com 1 worker (VPS tem 2 vCPU; evitar paralelismo pesado). +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/app/clientes/base.py b/app/clientes/base.py index 7c366b5..81bc6c4 100644 --- a/app/clientes/base.py +++ b/app/clientes/base.py @@ -1,55 +1,31 @@ import os import tempfile -from abc import ABC, abstractmethod -from typing import Any +from abc import ABC +from functools import cached_property -from fastapi import HTTPException +from app.schemas import HolmesCliente +from app.services.holmes import HolmesAPI from pydantic import BaseModel -class HolmesCliente(BaseModel): - """Credenciais do Holmes. Cada cliente tem a propria conta de integracao.""" - - token_api: str - usuario: str - senha: str - - class ClienteConfig(BaseModel): - """ - Os DADOS do cliente. - - Fica separado da classe de proposito: dado da pra carregar do banco, - classe nao. No dia que cliente novo deixar de exigir deploy, e daqui - que a config vai sair — o resto do codigo nao muda. - """ - nome: str razao_social: str cnpj: str holmes: HolmesCliente + # Chave publica (PEM) da API do cliente. Ciframos com ela; so a privada, + # que fica com eles, abre. + chave_publica: str class ClienteBase(ABC): - """ - Base de todo cliente atendido pela API. + # A subclasse atribui direto: config = ClienteConfig(...) + config: ClienteConfig - Regra de ouro: aqui so entra o que muda de COMPORTAMENTO entre clientes. - Valor (CNPJ, token, id de fluxo) vai em `config`, nao em atributo de classe - solto — senao a heranca vira dicionario com passos extras. - - A subclasse satisfaz `config` e `modulos` como atributo de classe mesmo, - nao precisa escrever @property. - """ - - @property - @abstractmethod - def config(self) -> ClienteConfig: ... - - @property - @abstractmethod - def modulos(self) -> dict[str, Any]: - """Processos habilitados: {"compras": ScarduaCompras(), ...}""" + def __init_subclass__(cls, **kwargs): + super().__init_subclass__(**kwargs) + if not hasattr(cls, "config"): + raise TypeError(f"{cls.__name__} precisa definir `config`") # --- comum a todos: ninguem sobrescreve --- @@ -57,13 +33,18 @@ class ClienteBase(ABC): def nome(self) -> str: return self.config.nome - def modulo(self, nome: str) -> Any: - """Pega um processo habilitado do cliente, ou 404 se ele nao tem.""" - if modulo := self.modulos.get(nome): - return modulo - raise HTTPException( - status_code=404, - detail=f"modulo '{nome}' nao habilitado para {self.nome}", + @cached_property + def holmes(self) -> HolmesAPI: + """ + API do Holmes ja amarrada nas credenciais deste cliente. + + cached_property e nao property: uma instancia por cliente, criada no + primeiro uso. Com @property comum sairia uma HolmesAPI nova a cada + acesso, e qualquer estado em memoria (token) se perderia. + """ + return HolmesAPI( + credenciais=self.config.holmes, + cache_token=self.cache_token_holmes, ) @property @@ -71,8 +52,8 @@ class ClienteBase(ABC): """ Arquivo de cache do token do Holmes, um por cliente. - Sem isso dois clientes com contas diferentes sobrescrevem o token - um do outro (ver _TOKEN_FILE em app/services/holmes.py). + Sem isso dois clientes com contas diferentes sobrescreveriam o token + um do outro — era o caso quando isso era uma constante de modulo. """ return os.path.join( tempfile.gettempdir(), f"holmes_token_{self.nome}.json" diff --git a/app/config.py b/app/config.py index 16996dc..f78f8a7 100644 --- a/app/config.py +++ b/app/config.py @@ -1,45 +1,6 @@ from pathlib import Path -from pydantic import BaseModel - - -class BancoConfig(BaseModel): - user: str - password: str - dns: str - instant_client: str - - -class HolmesConfig(BaseModel): - token_api: str - usuario: str - senha: str - - -class ApolloConfig(BaseModel): - subscription_key: str - ambiente: str - - -class ApiConfig(BaseModel): - port: int - ambiente: str - workers: int - - -class DocsConfig(BaseModel): - user: str - password: str - - -class Settings(BaseModel): - banco: BancoConfig - holmes: HolmesConfig - api: ApiConfig - apollo: ApolloConfig - docs: DocsConfig - log_level: str = "INFO" - +from app.schemas import Settings _PATH = Path(__file__).resolve().parent.parent / "configs.json" settings = Settings.model_validate_json(_PATH.read_text(encoding="utf-8")) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..f0bfd0b --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,32 @@ +from pydantic import BaseModel + + +class HolmesCliente(BaseModel): + """Credenciais do Holmes. Cada cliente tem a propria conta de integracao.""" + + token_api: str + usuario: str + senha: str + + +class ApiConfig(BaseModel): + port: int + ambiente: str + workers: int + + +class DocsConfig(BaseModel): + user: str + password: str + + +class ClienteSettings(BaseModel): + holmes: HolmesCliente + chave_publica: str + + +class Settings(BaseModel): + api: ApiConfig + clientes: dict[str, ClienteSettings] + docs: DocsConfig + log_level: str = "INFO" diff --git a/app/services/cripto.py b/app/services/cripto.py new file mode 100644 index 0000000..ec4cf55 --- /dev/null +++ b/app/services/cripto.py @@ -0,0 +1,99 @@ +""" +Criptografia do payload enviado pra API do cliente. + +Envelope (hibrida): AES-256-GCM nos dados, RSA-OAEP na chave AES. + +Por que nao RSA puro: RSA-2048 com OAEP-SHA256 cifra no maximo 190 bytes. +Um payload de compras passa disso facil. O padrao — mesmo do TLS, PGP e JWE — +e sortear uma chave simetrica por mensagem, cifrar os dados com ela e mandar +essa chave cifrada com a publica do destinatario. + +So o dono da chave privada abre. Nos ciframos e nao conseguimos desfazer, +que era o objetivo. +""" + +import base64 +import json +import os + +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import padding, rsa +from cryptography.hazmat.primitives.ciphers.aead import AESGCM + +ALGORITMO = "RSA-OAEP-256+A256GCM" + +_OAEP = padding.OAEP( + mgf=padding.MGF1(algorithm=hashes.SHA256()), + algorithm=hashes.SHA256(), + label=None, +) + + +def carregar_chave_publica(pem: str): + """Le a chave publica do cliente (formato PEM, vinda do configs.json).""" + return serialization.load_pem_public_key(pem.encode()) + + +def carregar_chave_privada(pem: str, senha: bytes | None = None): + """So usada em teste. Em producao a privada fica com o cliente.""" + return serialization.load_pem_private_key(pem.encode(), password=senha) + + +def criptografar(dados: dict, chave_publica) -> dict: + """ + Cifra o dict e devolve o envelope pronto pra mandar no corpo do POST. + + Sai assim: + { + "alg": "RSA-OAEP-256+A256GCM", + "chave": "", + "nonce": "", + "dados": "" + } + """ + chave_aes = AESGCM.generate_key(bit_length=256) + nonce = os.urandom(12) + corpo = json.dumps(dados, ensure_ascii=False, separators=(",", ":")).encode() + + cifrado = AESGCM(chave_aes).encrypt(nonce, corpo, None) + chave_cifrada = chave_publica.encrypt(chave_aes, _OAEP) + + return { + "alg": ALGORITMO, + "chave": base64.b64encode(chave_cifrada).decode(), + "nonce": base64.b64encode(nonce).decode(), + "dados": base64.b64encode(cifrado).decode(), + } + + +def descriptografar(envelope: dict, chave_privada) -> dict: + """ + O lado do cliente. Fica aqui pra testar o round-trip e pra servir de + referencia de implementacao pra quem for escrever a API que recebe. + """ + chave_aes = chave_privada.decrypt(base64.b64decode(envelope["chave"]), _OAEP) + corpo = AESGCM(chave_aes).decrypt( + base64.b64decode(envelope["nonce"]), + base64.b64decode(envelope["dados"]), + None, + ) + return json.loads(corpo) + + +def gerar_par_de_chaves() -> tuple[str, str]: + """Gera um par RSA-2048 em PEM. Util pra homologacao: (privada, publica).""" + chave = rsa.generate_private_key(public_exponent=65537, key_size=2048) + privada = chave.private_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PrivateFormat.PKCS8, + encryption_algorithm=serialization.NoEncryption(), + ).decode() + publica = ( + chave.public_key() + .public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo, + ) + .decode() + ) + return privada, publica diff --git a/app/services/holmes.py b/app/services/holmes.py index b4020ea..0fe04a5 100644 --- a/app/services/holmes.py +++ b/app/services/holmes.py @@ -4,83 +4,14 @@ import logging import os import random import re -import tempfile import time import httpx -from app.config import settings +from app.schemas import HolmesCliente from app.services.controle_api import registrar_contador from oracledb import Connection -_TOKEN_FILE = os.path.join(tempfile.gettempdir(), "holmes_token_cache.json") - - -def _ler_token_arquivo() -> dict | None: - try: - with open(_TOKEN_FILE) as f: - return json.load(f) - except Exception: - return None - - -def _salvar_token_arquivo(token: dict): - try: - with open(_TOKEN_FILE, "w") as f: - json.dump(token, f) - except Exception: - pass - - -def _invalidar_token_usuario(): - try: - os.remove(_TOKEN_FILE) - except Exception: - pass - - -async def token_usuario(): - cached = _ler_token_arquivo() - if cached: - return cached - await asyncio.sleep(random.uniform(0, 0.5)) - cached = _ler_token_arquivo() - if cached: - return cached - url = "https://app-api.holmesdoc.io/v1/session" - body = {"email": settings.holmes.usuario , "password": settings.holmes.senha} - headers = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"} - async with httpx.AsyncClient() as client: - response = await client.post(url, json=body, headers=headers) - response.raise_for_status() - token = {"authorization": response.json()["token"]} - _salvar_token_arquivo(token) - return token - - -async def get_header() -> tuple[dict, str]: - if random.randint(1, 1) >= 2: - return {"api_token": settings.holmes.token_api}, "holmes" - header = await token_usuario() - return header, "holmes_user" - - -async def _holmes_request( - method: str, url: str, **kwargs -) -> tuple[httpx.Response, str]: - api_nome: str = "holmes" - for tentativa in range(2): - header, api_nome = await get_header() - async with httpx.AsyncClient() as client: - response = await getattr(client, method)(url, headers=header, **kwargs) - if ( - response.status_code == 401 - and api_nome == "holmes_user" - and tentativa == 0 - ): - _invalidar_token_usuario() - continue - return response, api_nome - raise RuntimeError("Holmes: falha de autenticação após retry") +# Ricardinho aqui, como esse projeto vai ser para varios clientes tem que alterar a logica desse arquivo, colocar em clase que um cliente herda def extrair_origem(origem: str): @@ -123,246 +54,397 @@ def extrair_transacao(transacao: str) -> str: return transacao[:3] -async def get_holmes_process(id_processo: str, conn: Connection): +class HolmesAPI: """ - Busca um processo no Holmes. Centralizado para Peças e Despesas. + Cliente da API do Holmes, amarrado a UMA conta de integracao. + + Nao importa nada de app.clientes de proposito: quem conhece o cliente e a + camada de cima. Aqui so entram as credenciais e o caminho do cache. + Instanciar por `cliente.holmes` (ver app/clientes/base.py). """ - url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}" - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" - try: - response, api_nome = await _holmes_request("get", url) - response.raise_for_status() - status = response.status_code - sucesso = True - return response.json() - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") - return None - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao consultar Holmes: {e}") - return None - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/processes/{id}", - metodo="GET", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) + def __init__(self, credenciais: HolmesCliente, cache_token: str): + self.cfg = credenciais + self.cache_token = cache_token + def _ler_token_arquivo(self) -> dict | None: + try: + with open(self.cache_token) as f: + return json.load(f) + except Exception: + return None -async def get_holmes_process_details(id_processo: str, conn: Connection): - """ - Busca os detalhes (properties) de um processo no Holmes. - """ - url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/details" - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" + def _salvar_token_arquivo(self, token: dict): + try: + with open(self.cache_token, "w") as f: + json.dump(token, f) + except Exception: + pass - try: - response, api_nome = await _holmes_request("get", url) - response.raise_for_status() - status = response.status_code - sucesso = True - return response.json() - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro na API Holmes details (ID {id_processo}): {e}") - return None - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao consultar Holmes details: {e}") - return None - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/processes/{id}/details", - metodo="GET", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) + def _invalidar_token_usuario(self): + try: + os.remove(self.cache_token) + except Exception: + pass + async def token_usuario(self): + cached = self._ler_token_arquivo() + if cached: + return cached + await asyncio.sleep(random.uniform(0, 0.5)) + cached = self._ler_token_arquivo() + if cached: + return cached + url = "https://app-api.holmesdoc.io/v1/session" + body = {"email": self.cfg.usuario , "password": self.cfg.senha} + headers = {"Content-Type": "application/json", "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/150.0.0.0 Safari/537.36"} + async with httpx.AsyncClient() as client: + response = await client.post(url, json=body, headers=headers) + response.raise_for_status() + token = {"authorization": response.json()["token"]} + self._salvar_token_arquivo(token) + return token -async def get_holmes_history(id_processo: str, conn: Connection): - """ - Busca o historico no holmes (importante para pegar o id da ultima task, para avançar futuramente) - Args: - id_processo (str): id do processo no holmes - """ - url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/history" - payload = { - "filters": [], - "page": 1, - "per_page": 100, - "sortBy": ["created_at", "desc"], - } - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" + async def get_header(self) -> tuple[dict, str]: + if random.randint(1, 1) >= 2: + return {"api_token": self.cfg.token_api}, "holmes" + header = await self.token_usuario() + return header, "holmes_user" - try: - response, api_nome = await _holmes_request("post", url, json=payload) - response.raise_for_status() - status = response.status_code - sucesso = True - return response.json() - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") - return None - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao consultar Holmes: {e}") - return None - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/processes/{id}/history", - metodo="POST", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) + async def _holmes_request( + self, method: str, url: str, **kwargs + ) -> tuple[httpx.Response, str]: + api_nome: str = "holmes" + for tentativa in range(2): + header, api_nome = await self.get_header() + async with httpx.AsyncClient() as client: + response = await getattr(client, method)(url, headers=header, **kwargs) + if ( + response.status_code == 401 + and api_nome == "holmes_user" + and tentativa == 0 + ): + self._invalidar_token_usuario() + continue + return response, api_nome + raise RuntimeError("Holmes: falha de autenticação após retry") + async def get_holmes_process(self, id_processo: str, conn: Connection): + """ + Busca um processo no Holmes. Centralizado para Peças e Despesas. + """ + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" -async def get_holmes_rateio(id_processo: str, conn: Connection): - url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/tables/e124b2d0-ee14-11ef-95b4-25dee32fe73f/table_items?page=1&per_page=800" - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" + try: + response, api_nome = await self._holmes_request("get", url) + response.raise_for_status() + status = response.status_code + sucesso = True + return response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") + return None + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return None + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}", + metodo="GET", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) - try: - response, api_nome = await _holmes_request("get", url) - response.raise_for_status() - status = response.status_code - sucesso = True - return response.json() - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") - return None - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao consultar Holmes: {e}") - return None - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/processes/{id}/table/(rateio)", - metodo="GET", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) + async def get_holmes_process_details(self, id_processo: str, conn: Connection): + """ + Busca os detalhes (properties) de um processo no Holmes. + """ + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/details" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + try: + response, api_nome = await self._holmes_request("get", url) + response.raise_for_status() + status = response.status_code + sucesso = True + return response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes details (ID {id_processo}): {e}") + return None + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes details: {e}") + return None + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/details", + metodo="GET", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) -async def task_id_recente(id_processo: str, conn: Connection): - dados_tasks = await get_holmes_history(id_processo, conn) + async def get_holmes_history(self, id_processo: str, conn: Connection): + """ + Busca o historico no holmes (importante para pegar o id da ultima task, para avançar futuramente) + Args: + id_processo (str): id do processo no holmes + """ + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/history" + payload = { + "filters": [], + "page": 1, + "per_page": 100, + "sortBy": ["created_at", "desc"], + } + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" - if ( - not dados_tasks - or "histories" not in dados_tasks - or not dados_tasks["histories"] - ): - return None + try: + response, api_nome = await self._holmes_request("post", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + return response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") + return None + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return None + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/history", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) - mais_recente = max(dados_tasks["histories"], key=lambda x: x["created_at"]) + async def get_holmes_rateio(self, id_processo: str, conn: Connection): + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/tables/e124b2d0-ee14-11ef-95b4-25dee32fe73f/table_items?page=1&per_page=800" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" - return mais_recente["properties"]["task_id"] + try: + response, api_nome = await self._holmes_request("get", url) + response.raise_for_status() + status = response.status_code + sucesso = True + return response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") + return None + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return None + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/table/(rateio)", + metodo="GET", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + async def task_id_recente(self, id_processo: str, conn: Connection): + dados_tasks = await self.get_holmes_history(id_processo, conn) -async def task_mais_recente(id_processo: str, conn: Connection): - dados_tasks = await get_holmes_history(id_processo, conn) + if ( + not dados_tasks + or "histories" not in dados_tasks + or not dados_tasks["histories"] + ): + return None - if not dados_tasks or "histories" not in dados_tasks: - return None # Tratamento se a API falhar + mais_recente = max(dados_tasks["histories"], key=lambda x: x["created_at"]) - # print(dados_tasks) + return mais_recente["properties"]["task_id"] - mais_recente = max(dados_tasks["histories"], key=lambda x: x["created_at"]) + async def task_mais_recente(self, id_processo: str, conn: Connection): + dados_tasks = await self.get_holmes_history(id_processo, conn) - return mais_recente + if not dados_tasks or "histories" not in dados_tasks: + return None # Tratamento se a API falhar + # print(dados_tasks) -async def historicos_task(id_processo: str, conn: Connection) -> dict | None: - """_summary_ + mais_recente = max(dados_tasks["histories"], key=lambda x: x["created_at"]) - Args: - id_processo (str): Id do processo no holmes + return mais_recente - Returns: - dict | None : dicionario do historico | None - """ - dados_tasks = await get_holmes_history(id_processo, conn) - if not dados_tasks or "histories" not in dados_tasks: - return None # Tratamento se a API falhar + async def historicos_task(self, id_processo: str, conn: Connection) -> dict | None: + """_summary_ - return dados_tasks + Args: + id_processo (str): Id do processo no holmes + Returns: + dict | None : dicionario do historico | None + """ + dados_tasks = await self.get_holmes_history(id_processo, conn) + if not dados_tasks or "histories" not in dados_tasks: + return None # Tratamento se a API falhar -async def buscar_processo( - conn: Connection, - chave: str | None = None, - fluxos: list[str] | bool = False, - ativos: bool = True, - payload: dict | bool = False, -) -> dict: - """Obtem os processos que existem com a sua chave + return dados_tasks - Args: - chave (str): Chave principal a ser procurada, preferencialmente unica pfvr, ajuda ae po. - ativos (bool) Defaults to True - fluxos (list[str] | bool, optional): _description_. Defaults to False. se quer pegar de um fluxo específico ou geral. Padrão: Geral + async def buscar_processo( + self, + conn: Connection, + chave: str | None = None, + fluxos: list[str] | bool = False, + ativos: bool = True, + payload: dict | bool = False, + ) -> dict: + """Obtem os processos que existem com a sua chave - Returns: - dict: _description_ - """ - if not payload and chave is None: - raise ValueError( - "Chave é obrigatória caso o payload não seja enviado chefia, fica alerta ae rapa" - ) + Args: + chave (str): Chave principal a ser procurada, preferencialmente unica pfvr, ajuda ae po. + ativos (bool) Defaults to True + fluxos (list[str] | bool, optional): _description_. Defaults to False. se quer pegar de um fluxo específico ou geral. Padrão: Geral - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" + Returns: + dict: _description_ + """ + if not payload and chave is None: + raise ValueError( + "Chave é obrigatória caso o payload não seja enviado chefia, fica alerta ae rapa" + ) - url = "https://app-api.holmesdoc.io/v2/search" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + url = "https://app-api.holmesdoc.io/v2/search" + + if not payload: + payload = { + "query": { + "from": 0, + "size": 200, + "context": "process", + "sort": "updated_at", + "order": "desc", + "groups": [ + { + "match_all": True, + "terms": [ + { + "value": f"{chave}", + "type": "match_phrase", + "field": "_content", + } + ], + } + ], + }, + "trash": False, + "deleted_by_me": False, + } + try: + response, api_nome = await self._holmes_request("post", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + dados = response.json() + docs = dados.get("docs", []) + if ativos: + docs = [d for d in docs if d.get("status") != "canceled"] + if fluxos: + docs = [d for d in docs if d.get("name") in fluxos] + return {"status": True, "dados": {**dados, "docs": docs, "total": len(docs)}} + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {chave}): {e}") + return {"status": False, "error": e} + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return {"status": False, "error": e} + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/search/por-chave", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + async def buscar_processo_por_chaves( + self, + conn: Connection, + combinacoes: list[list[str]], + fluxos: list[str] | bool = False, + ativos: bool = True, + ) -> dict: + """Busca processos no Holmes usando combinações de termos. + + Cada item de combinacoes é uma lista de valores que juntos identificam + um processo único (ex: [cnpj, numero_nf]). Cada combinação vira um group + separado na query. + + Args: + combinacoes: Ex: [["03657256000164", "19"], ["698cd1c570fd0f8f5f8436a4"]] + ativos: Ignora processos cancelados. Padrão: True. + fluxos: Filtra por nome de fluxo. Padrão: False (todos). + """ + url = "https://app-api.holmesdoc.io/v2/search" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" - if not payload: payload = { "query": { "from": 0, @@ -374,275 +456,234 @@ async def buscar_processo( { "match_all": True, "terms": [ - { - "value": f"{chave}", - "type": "match_phrase", - "field": "_content", - } + {"value": termo, "type": "match_phrase", "field": "_content"} + for termo in combinacao ], } + for combinacao in combinacoes ], }, "trash": False, "deleted_by_me": False, } - try: - response, api_nome = await _holmes_request("post", url, json=payload) - response.raise_for_status() - status = response.status_code - sucesso = True - dados = response.json() - docs = dados.get("docs", []) - if ativos: - docs = [d for d in docs if d.get("status") != "canceled"] - if fluxos: - docs = [d for d in docs if d.get("name") in fluxos] - return {"status": True, "dados": {**dados, "docs": docs, "total": len(docs)}} - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro na API Holmes (ID {chave}): {e}") - return {"status": False, "error": e} - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao consultar Holmes: {e}") - return {"status": False, "error": e} - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/processes/{id}/search/por-chave", - metodo="POST", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) - -async def buscar_processo_por_chaves( - conn: Connection, - combinacoes: list[list[str]], - fluxos: list[str] | bool = False, - ativos: bool = True, -) -> dict: - """Busca processos no Holmes usando combinações de termos. - - Cada item de combinacoes é uma lista de valores que juntos identificam - um processo único (ex: [cnpj, numero_nf]). Cada combinação vira um group - separado na query. - - Args: - combinacoes: Ex: [["03657256000164", "19"], ["698cd1c570fd0f8f5f8436a4"]] - ativos: Ignora processos cancelados. Padrão: True. - fluxos: Filtra por nome de fluxo. Padrão: False (todos). - """ - url = "https://app-api.holmesdoc.io/v2/search" - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" - - payload = { - "query": { - "from": 0, - "size": 200, - "context": "process", - "sort": "updated_at", - "order": "desc", - "groups": [ - { - "match_all": True, - "terms": [ - {"value": termo, "type": "match_phrase", "field": "_content"} - for termo in combinacao - ], - } - for combinacao in combinacoes - ], - }, - "trash": False, - "deleted_by_me": False, - } - - try: - response, api_nome = await _holmes_request("post", url, json=payload) - response.raise_for_status() - status = response.status_code - sucesso = True - dados = response.json() - docs = dados.get("docs", []) - if ativos: - docs = [d for d in docs if d.get("status") != "canceled"] - if fluxos: - docs = [d for d in docs if d.get("name") in fluxos] - return {"status": True, "dados": {**dados, "docs": docs, "total": len(docs)}} - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro na API Holmes (combinacoes {combinacoes}): {e}") - return {"status": False, "error": e} - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao consultar Holmes: {e}") - return {"status": False, "error": e} - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/processes/{id}/search/por-chaves", - metodo="POST", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) - - -async def action(payload: dict, id_task: str, id_processo: str, conn: Connection): - url = f"https://app-api.holmesdoc.io/v1/tasks/{id_task}/action" - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" - - try: - async with httpx.AsyncClient() as client: - response = await client.post( - url, headers={"api_token": settings.holmes.token_api}, json=payload + try: + response, api_nome = await self._holmes_request("post", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + dados = response.json() + docs = dados.get("docs", []) + if ativos: + docs = [d for d in docs if d.get("status") != "canceled"] + if fluxos: + docs = [d for d in docs if d.get("name") in fluxos] + return {"status": True, "dados": {**dados, "docs": docs, "total": len(docs)}} + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (combinacoes {combinacoes}): {e}") + return {"status": False, "error": e} + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return {"status": False, "error": e} + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/search/por-chaves", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, ) - response.raise_for_status() - status = response.status_code - sucesso = True - return True, response.json() - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") - return False, e - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao consultar Holmes: {e}") - return False, e - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/processes/{id}/action", - metodo="POST", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) + async def action( + self, payload: dict, id_task: str, id_processo: str, conn: Connection + ): + url = f"https://app-api.holmesdoc.io/v1/tasks/{id_task}/action" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" -async def cria_processo( - id_start: str, - payload: dict, - conn: Connection -) -> tuple[bool, dict | str]: - url = f"https://app-api.holmesdoc.io/v1/workflows/{id_start}/start" - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" - - try: - response, api_nome = await _holmes_request("post", url, json=payload) - response.raise_for_status() - status = response.status_code - sucesso = True - return True, response.json() - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro na API Holmes - Criar Processo ({payload}): {e}") - return False, str(e) - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao criar processo no Holmes: {e}") - return False, str(e) - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/workflows/{id}/start", - metodo="POST", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) - - -async def enviar_documento( - id_processo: str, - arquivo: bytes, - nome_arquivo: str, - id_documento: str, - conn: Connection, -) -> tuple[bool, str | dict]: - task_id = await task_id_recente(id_processo, conn) - - if not task_id: - return False, "Não foi possível obter a task mais recente do Holmes" - - url = f"https://app-api.holmesdoc.io/v1/tasks/{task_id}/documents/{id_documento}" - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" - - try: - files = {"file": (nome_arquivo, arquivo, "application/pdf")} - async with httpx.AsyncClient() as client: - response = await client.post( - url, headers={"api_token": settings.holmes.token_api}, files=files + try: + async with httpx.AsyncClient() as client: + response = await client.post( + url, headers={"api_token": self.cfg.token_api}, json=payload + ) + response.raise_for_status() + status = response.status_code + sucesso = True + return True, response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes (ID {id_processo}): {e}") + return False, e + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao consultar Holmes: {e}") + return False, e + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/action", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + async def cria_processo( + self, id_start: str, payload: dict, conn: Connection + ) -> tuple[bool, dict | str]: + url = f"https://app-api.holmesdoc.io/v1/workflows/{id_start}/start" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + response, api_nome = await self._holmes_request("post", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + return True, response.json() + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro na API Holmes - Criar Processo ({payload}): {e}") + return False, str(e) + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao criar processo no Holmes: {e}") + return False, str(e) + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/workflows/{id}/start", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + async def enviar_documento( + self, + id_processo: str, + arquivo: bytes, + nome_arquivo: str, + id_documento: str, + conn: Connection, + ) -> tuple[bool, str | dict]: + task_id = await self.task_id_recente(id_processo, conn) + + if not task_id: + return False, "Não foi possível obter a task mais recente do Holmes" + + url = f"https://app-api.holmesdoc.io/v1/tasks/{task_id}/documents/{id_documento}" + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + files = {"file": (nome_arquivo, arquivo, "application/pdf")} + async with httpx.AsyncClient() as client: + response = await client.post( + url, headers={"api_token": self.cfg.token_api}, files=files + ) + response.raise_for_status() + status = response.status_code + sucesso = True + return True, {"task_id": task_id} + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro ao enviar documento Holmes (ID {id_processo}): {e}") + return False, str(e) + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao enviar documento Holmes: {e}") + return False, str(e) + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/tasks/{id}/documents/{id_documento}", + metodo="POST", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, + ) + + async def cancela_processo( + self, id_processo: str, conn: Connection + ) -> tuple[bool, str | dict]: + url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/cancel" + payload = {"reason": "Erro na emissão, data de vencimento. Problema na Disal."} + inicio = time.perf_counter() + status = None + sucesso = False + erro = None + api_nome = "holmes" + + try: + response, api_nome = await self._holmes_request("put", url, json=payload) + response.raise_for_status() + status = response.status_code + sucesso = True + return True, response.json() if response.content else { + "mensagem": "processo cancelado" + } + except httpx.HTTPStatusError as e: + status = e.response.status_code + erro = str(e) + logging.error(f"Erro ao cancelar processo Holmes (ID {id_processo}): {e}") + return False, str(e) + except Exception as e: + erro = str(e) + logging.error(f"Erro inesperado ao cancelar processo Holmes: {e}") + return False, str(e) + finally: + duracao = (time.perf_counter() - inicio) * 1000 + registrar_contador( + conn=conn, + api=api_nome, + endpoint="/v1/processes/{id}/cancel", + metodo="PUT", + status_code=status, + sucesso=sucesso, + duracao_ms=duracao, + erro=erro, ) - response.raise_for_status() - status = response.status_code - sucesso = True - return True, {"task_id": task_id} - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro ao enviar documento Holmes (ID {id_processo}): {e}") - return False, str(e) - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao enviar documento Holmes: {e}") - return False, str(e) - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/tasks/{id}/documents/{id_documento}", - metodo="POST", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) # Para testar as funcoes # async def main(): +# from app.clientes.registry import obter # from app.infra.database import db_instance # db_instance.create_pool() # conn = db_instance.pool.acquire() # try: -# print(await get_holmes_history('69e65ea83fad950fad5715ed', conn)) +# holmes = obter('scardua').holmes +# print(await holmes.get_holmes_history('69e65ea83fad950fad5715ed', conn)) # finally: # db_instance.pool.release(conn) @@ -651,51 +692,10 @@ async def enviar_documento( # asyncio.run(main()) -async def cancela_processo( - id_processo: str, conn: Connection -) -> tuple[bool, str | dict]: - url = f"https://app-api.holmesdoc.io/v1/processes/{id_processo}/cancel" - payload = {"reason": "Erro na emissão, data de vencimento. Problema na Disal."} - inicio = time.perf_counter() - status = None - sucesso = False - erro = None - api_nome = "holmes" - - try: - response, api_nome = await _holmes_request("put", url, json=payload) - response.raise_for_status() - status = response.status_code - sucesso = True - return True, response.json() if response.content else { - "mensagem": "processo cancelado" - } - except httpx.HTTPStatusError as e: - status = e.response.status_code - erro = str(e) - logging.error(f"Erro ao cancelar processo Holmes (ID {id_processo}): {e}") - return False, str(e) - except Exception as e: - erro = str(e) - logging.error(f"Erro inesperado ao cancelar processo Holmes: {e}") - return False, str(e) - finally: - duracao = (time.perf_counter() - inicio) * 1000 - registrar_contador( - conn=conn, - api=api_nome, - endpoint="/v1/processes/{id}/cancel", - metodo="PUT", - status_code=status, - sucesso=sucesso, - duracao_ms=duracao, - erro=erro, - ) - - async def main(): - # print(aaaa()) - print(await token_usuario()) + from app.clientes.registry import obter + + print(await obter("scardua").holmes.token_usuario()) if __name__ == "__main__": diff --git a/app/v1/api.py b/app/v1/api.py index 6aa1576..334ec32 100644 --- a/app/v1/api.py +++ b/app/v1/api.py @@ -1,9 +1,12 @@ -from app.v1.holmes.holmes import holmes_router +import app.v1.scardua.cliente # noqa: F401 (registra o Scardua no registry) +from app.v1.scardua.scardua import holmes_router from fastapi import APIRouter api_router = APIRouter() +# O {cliente} vira path param e cai no get_cliente por Depends. +# Rota final: POST /v1/holmes/scardua/compras/dados api_router.include_router( holmes_router, - prefix='/holmes' + prefix="/holmes/{cliente}", ) diff --git a/app/v1/scardua/cliente.py b/app/v1/scardua/cliente.py new file mode 100644 index 0000000..121c41e --- /dev/null +++ b/app/v1/scardua/cliente.py @@ -0,0 +1,16 @@ +from app.clientes.base import ClienteBase, ClienteConfig +from app.clientes.registry import registrar +from app.config import settings + + +class Scardua(ClienteBase): + config = ClienteConfig( + nome="scardua", + razao_social="Comercial Scardua", + cnpj="", + holmes=settings.clientes["scardua"].holmes, + chave_publica=settings.clientes["scardua"].chave_publica, + ) + + +registrar(Scardua()) diff --git a/app/v1/scardua/modules/compras/router.py b/app/v1/scardua/modules/compras/router.py index 1f1b012..d30d1d7 100644 --- a/app/v1/scardua/modules/compras/router.py +++ b/app/v1/scardua/modules/compras/router.py @@ -1,4 +1,6 @@ -from fastapi import APIRouter +from app.clientes.base import ClienteBase +from app.clientes.registry import get_cliente +from fastapi import APIRouter, Depends from .schemas import DadosCompras from .service import processar_compras @@ -6,5 +8,8 @@ from .service import processar_compras router = APIRouter(prefix="/compras", tags=["compras"]) @router.post("/dados") -async def dados_compras(dados: DadosCompras): - return await processar_compras(dados) +async def dados_compras( + dados: DadosCompras, + cli: ClienteBase = Depends(get_cliente), +): + return await processar_compras(dados, cli) diff --git a/app/v1/scardua/modules/compras/schemas.py b/app/v1/scardua/modules/compras/schemas.py index efc3fe0..e49803b 100644 --- a/app/v1/scardua/modules/compras/schemas.py +++ b/app/v1/scardua/modules/compras/schemas.py @@ -1,6 +1,6 @@ from datetime import datetime -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field class Parcela(BaseModel): @@ -8,13 +8,24 @@ class Parcela(BaseModel): vencimento: datetime | None = None +class Autor(BaseModel): + id: str + name: str + email: str + + class ComprasProperties(BaseModel): + # O Holmes manda PEDIDO_LINX como numero (345, sem aspas). O Pydantic v2 + # nao converte numero pra string sozinho, entao precisa habilitar. + model_config = ConfigDict(coerce_numbers_to_str=True) + protocolo: str pedido_linx: str = Field(alias="PEDIDO_LINX") fornecedor: str = Field(alias="Fornecedores") tipo_compra: str = Field(alias="Tipo de Compra") cnpj: str = Field(alias="CNPJ") - nf_entrada: str = Field(alias="NUMERO NF ENTRADA") + # Chega vazio enquanto o processo nao passou pela fase da nota. + nf_entrada: str | None = Field(default=None, alias="NUMERO NF ENTRADA") num_parcelas: int | None = Field(default=None, alias="num. parcelas") @@ -64,14 +75,21 @@ class ComprasProperties(BaseModel): class DadosCompras(BaseModel): id: str + author: Autor properties: ComprasProperties + class PayloadCompras(BaseModel): + # Chave de deduplicacao: o id do processo no Holmes. Como ele reentrega + # webhook, sem isso todo reenvio vira linha duplicada no banco do cliente. + id_processo: str + + protocolo: str cnpj: str pedido_linx: str fornecedor: str tipo: str - nf_entrada: str + nf_entrada: str | None aprovador: str valor_total: float parcelas: dict[int, Parcela] diff --git a/app/v1/scardua/modules/compras/service.py b/app/v1/scardua/modules/compras/service.py index ca03de5..b5f7b33 100644 --- a/app/v1/scardua/modules/compras/service.py +++ b/app/v1/scardua/modules/compras/service.py @@ -1,26 +1,41 @@ +from app.clientes.base import ClienteBase +from app.services import cripto + from .schemas import DadosCompras, PayloadCompras -from .service import processar_compras -from app.services.api_cliente import -from app.services.holmes import -async def processar_compras( - dados: DadosCompras -) -> dict: - payload = montar_payload(dados) - - ok, retorno = await apollo.enviar_compra(payload.model_dump(mode="json"), conn) - if not ok: - raise HTTPException(502, f"Falha ao enviar para a API externa: {retorno}") - return {"status": "enviado", "id_processo": dados.id, "retorno": retorno} - def montar_payload(dados: DadosCompras) -> PayloadCompras: + """ + Traduz o formato do Holmes pro formato que a API do cliente espera. + + Funcao pura, sem I/O: da pra testar jogando o JSON do Holmes e conferindo + a saida, sem rede nem mock. + """ p = dados.properties return PayloadCompras( + id_processo=dados.id, + protocolo=p.protocolo, cnpj=p.cnpj, pedido_linx=p.pedido_linx, fornecedor=p.fornecedor, tipo=p.tipo_compra, + nf_entrada=p.nf_entrada, + aprovador=dados.author.name, valor_total=p.total_parcelas, parcelas=p.parcelas, ) + + +def montar_envelope(dados: DadosCompras, cli: ClienteBase) -> dict: + """Monta o payload e cifra com a chave publica do cliente.""" + payload = montar_payload(dados) + chave = cripto.carregar_chave_publica(cli.config.chave_publica) + return cripto.criptografar(payload.model_dump(mode="json"), chave) + + +async def processar_compras(dados: DadosCompras, cli: ClienteBase) -> dict: + envelope = montar_envelope(dados, cli) + + # TODO: POST do envelope pra API do cliente. Erro tem que subir (nao + # engolir), pra o Holmes reentregar — ele e a nossa fila. + return {"status": "pendente_envio", "id_processo": dados.id, "envelope": envelope} diff --git a/docs/deploy.md b/docs/deploy.md new file mode 100644 index 0000000..de3248b --- /dev/null +++ b/docs/deploy.md @@ -0,0 +1,115 @@ +# Deploy — API (VPS da FLS) + +Ambiente: **VPS Hostinger** (Debian 13), **Podman rootless + Quadlet**, **Caddy** +(proxy + TLS), user `admin`. +No ar: **https://api-scardua.flstecnologia.tech** + +> ℹ️ O nome `api-scardua` é **legado** — a API hoje atende múltiplos clientes +> (`/v1/holmes/{cliente}/...`). Renomear implica mexer junto em: nome do quadlet, +> `Image=`, `ContainerName=`, pasta do `configs.json`, build dir, bloco do +> Caddyfile e DNS. O `reverse_proxy` do Caddy resolve o container pelo +> `ContainerName` dentro da rede `fls` — se um mudar sem o outro, dá **502**. + +## Acesso à VPS + +```bash +ssh vps-fls # alias -> 179.197.230.154, porta 3115, user admin +``` + +- Porta 22 é **fechada** no firewall. Sempre `-p 3115` (ou o alias). +- `systemctl --user` sempre (rootless) — **nunca** `sudo systemctl` pros containers. +- ⚠️ O `~/.ssh/config` tem um **BOM** na primeira linha e o OpenSSH do Git Bash + recusa (`Bad configuration option: \357\273\277host`). Contorno sem editar o + arquivo: + ```bash + ssh -F none -i ~/.ssh/id_ed25519 -p 3115 admin@179.197.230.154 + ``` + +## Arquitetura + +- Imagem: buildada **local na VPS** = `localhost/api-scardua:latest` (sem registry). +- Container na rede **`fls`** — só o Caddy alcança; a porta 8000 **não** é publicada. +- Config: `configs.json` montado read-only de `~/.config/api-scardua/configs.json`. +- Quadlet: `~/.config/containers/systemd/api-scardua.container`. +- Caddy: `api-scardua.flstecnologia.tech` → `api-scardua:8000`, TLS automático. + Caddyfile em `/srv/containers/stacks/caddy/Caddyfile`. + +## Atualizar a app + +```bash +# 1. marcar rollback ANTES de qualquer coisa +ssh vps-fls 'podman tag localhost/api-scardua:latest localhost/api-scardua:rollback-$(date +%F)' + +# 2. mandar o código (não tem rsync no Git Bash do Windows; tar resolve) +tar czf - --exclude='__pycache__' --exclude='*.pyc' --exclude='.ruff_cache' \ + main.py app requirements.txt Dockerfile .dockerignore pyproject.toml \ + | ssh vps-fls 'rm -rf ~/build/api-scardua && mkdir -p ~/build/api-scardua && tar xzf - -C ~/build/api-scardua' + +# 3. buildar e reiniciar +ssh vps-fls 'cd ~/build/api-scardua && podman build --format docker -t api-scardua:latest . \ + && systemctl --user restart api-scardua' +``` + +> ⚠️ **`--format docker` é obrigatório.** No formato OCI (padrão do Podman) o +> `HEALTHCHECK` do Dockerfile é **silenciosamente ignorado** — e é ele que o +> `podman auto-update` usa pra decidir rollback. Conferir depois do build: +> ```bash +> podman inspect localhost/api-scardua:latest --format '{{json .HealthCheck}}' +> ``` + +> ⚠️ Diretórios da build antiga podem estar **sem bit de escrita** (`dr-x------`) +> e travar o `rm -rf` no meio. Se acontecer: `chmod -R u+rwX ~/build/api-scardua`. + +## O configs.json da VPS é separado + +Ele **não** vem do repo (gitignored). Vive em `~/.config/api-scardua/configs.json` +e tem que ser atualizado à mão quando o formato do `Settings` mudar — senão o +container entra em **crash-loop** na validação do Pydantic, que roda no import. + +```bash +cat configs.json | ssh vps-fls 'cat > ~/.config/api-scardua/configs.json && chmod 600 $_' +``` + +## Rollback + +```bash +ssh vps-fls ' + podman tag localhost/api-scardua:rollback- localhost/api-scardua:latest + systemctl --user restart api-scardua +' +``` + +## Mexer no Caddy (com segurança) + +```bash +cd /srv/containers/stacks/caddy +cp Caddyfile Caddyfile.bak +# editar... +podman exec caddy caddy validate --config /etc/caddy/Caddyfile # valida ANTES +podman exec caddy caddy reload --config /etc/caddy/Caddyfile # sem downtime +``` + +## Comandos úteis + +```bash +systemctl --user status api-scardua +podman logs -f api-scardua +podman ps --filter name=api-scardua # estado + health +podman healthcheck run api-scardua +``` + +## Verificar depois de subir + +```bash +curl -s -o /dev/null -w '%{http_code}\n' https://api-scardua.flstecnologia.tech/health +curl -X POST https://api-scardua.flstecnologia.tech/v1/holmes/scardua/compras/dados \ + -H 'Content-Type: application/json' -d @payload-do-holmes.json +``` + +Cliente desconhecido na rota deve dar **404** (`cliente 'x' nao atendido`). + +## Evolução planejada + +- **Registry + CI** (Forgejo Actions): publicar em `git.flstecnologia.tech`, trocar + o Quadlet pro alvo com `AutoUpdate=registry` → deploy pull-based. +- Backup do Postgres da VPS (pendência geral do servidor). diff --git a/requirements.txt b/requirements.txt index c73bb66..e6aaeff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,7 +1,27 @@ -# Dependencias de runtime da API (espelham o [project.dependencies] do pyproject.toml) -# Usado pelo Dockerfile para evitar o build do pacote (que dependeria de README.md) -fastapi>=0.115 -uvicorn[standard]>=0.34 -pydantic>=2.0 -httpx>=0.27 -oracledb>=2.0 +# gerado em 2026-08-16 11:39 | origem: pip freeze | python 3.13.13 +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.14.2 +certifi==2026.7.22 +cffi==2.1.1 +click==8.4.2 +colorama==0.4.6 +cryptography==50.0.0 +fastapi==0.141.1 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.8.0 +httpx==0.28.1 +idna==3.18 +oracledb==4.0.2 +pycparser==3.0 +pydantic==2.13.4 +pydantic_core==2.46.4 +python-dotenv==1.2.2 +PyYAML==6.0.3 +starlette==1.3.1 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +uvicorn==0.52.1 +watchfiles==1.2.0 +websockets==17.0.1