Commit Inicial
This commit is contained in:
commit
0f47953453
24 changed files with 1366 additions and 0 deletions
12
app/clientes/__init__.py
Normal file
12
app/clientes/__init__.py
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
from .base import ClienteBase, ClienteConfig, HolmesCliente
|
||||
from .registry import get_cliente, listar, obter, registrar
|
||||
|
||||
__all__ = [
|
||||
"ClienteBase",
|
||||
"ClienteConfig",
|
||||
"HolmesCliente",
|
||||
"get_cliente",
|
||||
"listar",
|
||||
"obter",
|
||||
"registrar",
|
||||
]
|
||||
82
app/clientes/base.py
Normal file
82
app/clientes/base.py
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
import os
|
||||
import tempfile
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any
|
||||
|
||||
from fastapi import HTTPException
|
||||
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
|
||||
|
||||
|
||||
class ClienteBase(ABC):
|
||||
"""
|
||||
Base de todo cliente atendido pela API.
|
||||
|
||||
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(), ...}"""
|
||||
|
||||
# --- comum a todos: ninguem sobrescreve ---
|
||||
|
||||
@property
|
||||
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}",
|
||||
)
|
||||
|
||||
@property
|
||||
def cache_token_holmes(self) -> str:
|
||||
"""
|
||||
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).
|
||||
"""
|
||||
return os.path.join(
|
||||
tempfile.gettempdir(), f"holmes_token_{self.nome}.json"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Cliente {self.nome}>"
|
||||
40
app/clientes/registry.py
Normal file
40
app/clientes/registry.py
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import logging
|
||||
|
||||
from fastapi import HTTPException
|
||||
|
||||
from .base import ClienteBase
|
||||
|
||||
_CLIENTES: dict[str, ClienteBase] = {}
|
||||
|
||||
|
||||
def registrar(cliente: ClienteBase) -> ClienteBase:
|
||||
"""Chamado no fim do modulo de cada cliente (app/v1/<cliente>/cliente.py)."""
|
||||
chave = cliente.nome.lower()
|
||||
if chave in _CLIENTES:
|
||||
logging.warning(f"Cliente '{chave}' registrado mais de uma vez, sobrescrevendo")
|
||||
_CLIENTES[chave] = cliente
|
||||
return cliente
|
||||
|
||||
|
||||
def obter(nome: str) -> ClienteBase:
|
||||
if cliente := _CLIENTES.get(nome.lower()):
|
||||
return cliente
|
||||
raise HTTPException(
|
||||
status_code=404, detail=f"cliente '{nome}' nao atendido por esta API"
|
||||
)
|
||||
|
||||
|
||||
def listar() -> list[str]:
|
||||
return sorted(_CLIENTES)
|
||||
|
||||
|
||||
def get_cliente(cliente: str) -> ClienteBase:
|
||||
"""
|
||||
Dependencia do FastAPI. Resolve o cliente pelo path param da rota:
|
||||
|
||||
@router.post("/{cliente}/compras/dados")
|
||||
async def rota(cli: ClienteBase = Depends(get_cliente)): ...
|
||||
|
||||
O nome do parametro tem que ser `cliente` pra bater com o {cliente} da rota.
|
||||
"""
|
||||
return obter(cliente)
|
||||
45
app/config.py
Normal file
45
app/config.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
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"
|
||||
|
||||
|
||||
_PATH = Path(__file__).resolve().parent.parent / "configs.json"
|
||||
settings = Settings.model_validate_json(_PATH.read_text(encoding="utf-8"))
|
||||
45
app/middleware.py
Normal file
45
app/middleware.py
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
import re
|
||||
|
||||
from fastapi import Request, status
|
||||
from fastapi.responses import Response
|
||||
|
||||
BLOCKED_PATHS = {
|
||||
"/",
|
||||
"/metrics",
|
||||
"/security.txt",
|
||||
"/.env",
|
||||
"/wp-admin",
|
||||
"/wp-login.php",
|
||||
"/admin",
|
||||
"/config",
|
||||
"/actuator",
|
||||
"/nice%20ports%2C/Trinity.txt.bak",
|
||||
}
|
||||
|
||||
BLOCKED_UA_PATTERNS = re.compile(
|
||||
r"(nmap|nikto|masscan|zgrab|censys|shodan|nuclei|httpx|gobuster|dirbuster)",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
|
||||
|
||||
async def block_scanners(request: Request, call_next):
|
||||
path = request.url.path
|
||||
ua = request.headers.get("user-agent", "")
|
||||
|
||||
if (
|
||||
path.startswith("/v1/")
|
||||
or path.startswith("/v2/")
|
||||
or path in ("/health", "/dados_retorno", "/token")
|
||||
):
|
||||
return await call_next(request)
|
||||
|
||||
if path in BLOCKED_PATHS or path.endswith((".bak", ".env", ".git", ".php")):
|
||||
return Response(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if BLOCKED_UA_PATTERNS.search(ua):
|
||||
return Response(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
if not ua:
|
||||
return Response(status_code=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
return await call_next(request)
|
||||
57
app/security.py
Normal file
57
app/security.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import jwt
|
||||
from app.config import settings
|
||||
from app.infra.database import db_instance
|
||||
from fastapi import Depends, HTTPException, status
|
||||
from fastapi.security import OAuth2PasswordBearer
|
||||
|
||||
ALGORITHM = "HS256"
|
||||
TOKEN_EXPIRE_MINUTES = 5
|
||||
|
||||
oauth2_scheme = OAuth2PasswordBearer(tokenUrl="/token")
|
||||
|
||||
|
||||
def criar_token(data: dict) -> str:
|
||||
payload = data.copy()
|
||||
payload["exp"] = datetime.now(timezone.utc) + timedelta(
|
||||
minutes=TOKEN_EXPIRE_MINUTES
|
||||
)
|
||||
return jwt.encode(payload, settings.api.jwt_secret, algorithm=ALGORITHM)
|
||||
|
||||
|
||||
def verificar_credenciais(client_id: str, client_secret: str) -> bool:
|
||||
db_instance.create_pool()
|
||||
assert db_instance.pool is not None
|
||||
conn = db_instance.pool.acquire()
|
||||
try:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
SELECT 1
|
||||
FROM orvel_ti.CAD_USUARIO
|
||||
WHERE PERFIL_TI = 'operador_ti'
|
||||
AND ativo = 'S'
|
||||
AND API = 'S'
|
||||
AND login = :login
|
||||
AND senha = :senha
|
||||
""",
|
||||
{"login": client_id, "senha": client_secret},
|
||||
)
|
||||
return cursor.fetchone() is not None
|
||||
finally:
|
||||
db_instance.pool.release(conn)
|
||||
|
||||
|
||||
async def token_valido(token: str = Depends(oauth2_scheme)) -> dict:
|
||||
try:
|
||||
payload = jwt.decode(token, settings.api.jwt_secret, algorithms=[ALGORITHM])
|
||||
return payload
|
||||
except jwt.ExpiredSignatureError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token expirado"
|
||||
)
|
||||
except jwt.InvalidTokenError:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token inválido"
|
||||
)
|
||||
0
app/services/api_cliente.py
Normal file
0
app/services/api_cliente.py
Normal file
33
app/services/controle_api.py
Normal file
33
app/services/controle_api.py
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# app/services/api_tracker.py
|
||||
import logging
|
||||
|
||||
|
||||
def registrar_contador(
|
||||
conn, api, endpoint, metodo, status_code, sucesso, duracao_ms, erro=None
|
||||
):
|
||||
"""Grava uma linha na tabela contadores_api."""
|
||||
cursor = conn.cursor()
|
||||
try:
|
||||
cursor.execute(
|
||||
"""
|
||||
INSERT INTO orvel_ti.contadores_api
|
||||
(api, endpoint, metodo_http, status_code, sucesso, duracao_ms, mensagem_erro)
|
||||
VALUES
|
||||
(:api, :endpoint, :metodo, :status, :sucesso, :duracao, :erro)
|
||||
""",
|
||||
{
|
||||
"api": api,
|
||||
"endpoint": endpoint,
|
||||
"metodo": metodo,
|
||||
"status": status_code,
|
||||
"sucesso": "S" if sucesso else "N",
|
||||
"duracao": duracao_ms,
|
||||
"erro": erro[:500] if erro else None,
|
||||
},
|
||||
)
|
||||
conn.commit()
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logging.error(f"Falha ao gravar contador: {e}")
|
||||
finally:
|
||||
cursor.close()
|
||||
704
app/services/holmes.py
Normal file
704
app/services/holmes.py
Normal file
|
|
@ -0,0 +1,704 @@
|
|||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import random
|
||||
import re
|
||||
import tempfile
|
||||
import time
|
||||
|
||||
import httpx
|
||||
from app.config import settings
|
||||
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")
|
||||
|
||||
|
||||
def extrair_origem(origem: str):
|
||||
"""Extrair origem da string do Holmes
|
||||
|
||||
Args:
|
||||
origem (str): 1548 Entrada de Freio
|
||||
|
||||
Returns:
|
||||
str: 1548
|
||||
"""
|
||||
return str(origem[:4])
|
||||
|
||||
|
||||
def extrair_empresa(unidade: str):
|
||||
"""Extrai a empresa da string inteira do holmes
|
||||
|
||||
Args:
|
||||
unidade (str): Ex 10.1 Hyundai Teix. Freitas
|
||||
|
||||
Returns:
|
||||
empresa: 10 | None
|
||||
revenda: 1 | None
|
||||
"""
|
||||
match = re.search(r"^(\d+)\.(\d+)", unidade)
|
||||
if match:
|
||||
return match.group(1), match.group(2)
|
||||
return None, None
|
||||
|
||||
|
||||
def extrair_transacao(transacao: str) -> str:
|
||||
"""Extrai apenas a parte transação do texto do Holmes
|
||||
|
||||
Args:
|
||||
transacao (str): D15 Entrada de Nota
|
||||
|
||||
Returns:
|
||||
str: D15
|
||||
"""
|
||||
return transacao[:3]
|
||||
|
||||
|
||||
async def get_holmes_process(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"
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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"
|
||||
|
||||
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 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 _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(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
|
||||
|
||||
mais_recente = max(dados_tasks["histories"], key=lambda x: x["created_at"])
|
||||
|
||||
return mais_recente["properties"]["task_id"]
|
||||
|
||||
|
||||
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:
|
||||
return None # Tratamento se a API falhar
|
||||
|
||||
# print(dados_tasks)
|
||||
|
||||
mais_recente = max(dados_tasks["histories"], key=lambda x: x["created_at"])
|
||||
|
||||
return mais_recente
|
||||
|
||||
|
||||
async def historicos_task(id_processo: str, conn: Connection) -> dict | None:
|
||||
"""_summary_
|
||||
|
||||
Args:
|
||||
id_processo (str): Id do processo no holmes
|
||||
|
||||
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
|
||||
|
||||
return dados_tasks
|
||||
|
||||
|
||||
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
|
||||
|
||||
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
|
||||
|
||||
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"
|
||||
)
|
||||
|
||||
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 _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
|
||||
)
|
||||
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(
|
||||
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
|
||||
)
|
||||
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.infra.database import db_instance
|
||||
# db_instance.create_pool()
|
||||
# conn = db_instance.pool.acquire()
|
||||
# try:
|
||||
# print(await get_holmes_history('69e65ea83fad950fad5715ed', conn))
|
||||
# finally:
|
||||
# db_instance.pool.release(conn)
|
||||
|
||||
# if __name__ == '__main__':
|
||||
# import asyncio
|
||||
# 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())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import asyncio
|
||||
|
||||
asyncio.run(main())
|
||||
9
app/v1/api.py
Normal file
9
app/v1/api.py
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
from app.v1.holmes.holmes import holmes_router
|
||||
from fastapi import APIRouter
|
||||
|
||||
api_router = APIRouter()
|
||||
|
||||
api_router.include_router(
|
||||
holmes_router,
|
||||
prefix='/holmes'
|
||||
)
|
||||
0
app/v1/scardua/modules/compras/config.py
Normal file
0
app/v1/scardua/modules/compras/config.py
Normal file
10
app/v1/scardua/modules/compras/router.py
Normal file
10
app/v1/scardua/modules/compras/router.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from .schemas import DadosCompras
|
||||
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)
|
||||
77
app/v1/scardua/modules/compras/schemas.py
Normal file
77
app/v1/scardua/modules/compras/schemas.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class Parcela(BaseModel):
|
||||
valor: float
|
||||
vencimento: datetime | None = None
|
||||
|
||||
|
||||
class ComprasProperties(BaseModel):
|
||||
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")
|
||||
|
||||
num_parcelas: int | None = Field(default=None, alias="num. parcelas")
|
||||
|
||||
parcela1: float | None = Field(default=None, alias="parcela1")
|
||||
parcela2: float | None = Field(default=None, alias="parcela2")
|
||||
parcela3: float | None = Field(default=None, alias="parcela3")
|
||||
parcela4: float | None = Field(default=None, alias="parcela4")
|
||||
parcela5: float | None = Field(default=None, alias="parcela5")
|
||||
parcela6: float | None = Field(default=None, alias="parcela6")
|
||||
parcela7: float | None = Field(default=None, alias="parcela7")
|
||||
parcela8: float | None = Field(default=None, alias="parcela8")
|
||||
parcela9: float | None = Field(default=None, alias="parcela9")
|
||||
parcela10: float | None = Field(default=None, alias="parcela10")
|
||||
parcela11: float | None = Field(default=None, alias="parcela11")
|
||||
parcela12: float | None = Field(default=None, alias="parcela12")
|
||||
|
||||
vencimento1: datetime | None = Field(default=None, alias="vencimento parcela1")
|
||||
vencimento2: datetime | None = Field(default=None, alias="vencimento parcela2")
|
||||
vencimento3: datetime | None = Field(default=None, alias="vencimento parcela3")
|
||||
vencimento4: datetime | None = Field(default=None, alias="vencimento parcela4")
|
||||
vencimento5: datetime | None = Field(default=None, alias="vencimento parcela5")
|
||||
vencimento6: datetime | None = Field(default=None, alias="vencimento parcela6")
|
||||
vencimento7: datetime | None = Field(default=None, alias="vencimento parcela7")
|
||||
vencimento8: datetime | None = Field(default=None, alias="vencimento parcela8")
|
||||
vencimento9: datetime | None = Field(default=None, alias="vencimento parcela9")
|
||||
vencimento10: datetime | None = Field(default=None, alias="vencimento parcela10")
|
||||
vencimento11: datetime | None = Field(default=None, alias="vencimento parcela11")
|
||||
vencimento12: datetime | None = Field(default=None, alias="vencimento parcela12")
|
||||
|
||||
@property
|
||||
def parcelas(self) -> dict[int, Parcela]:
|
||||
"""Junta parcelaN + vencimento parcelaN, ignorando os slots vazios."""
|
||||
parcelas = {}
|
||||
for n in range(1, 13):
|
||||
valor = getattr(self, f"parcela{n}")
|
||||
if valor is None:
|
||||
continue
|
||||
parcelas[n] = Parcela(
|
||||
valor=valor,
|
||||
vencimento=getattr(self, f"vencimento{n}"),
|
||||
)
|
||||
return parcelas
|
||||
|
||||
@property
|
||||
def total_parcelas(self) -> float:
|
||||
return round(sum(p.valor for p in self.parcelas.values()), 2)
|
||||
|
||||
class DadosCompras(BaseModel):
|
||||
id: str
|
||||
properties: ComprasProperties
|
||||
|
||||
class PayloadCompras(BaseModel):
|
||||
cnpj: str
|
||||
pedido_linx: str
|
||||
fornecedor: str
|
||||
tipo: str
|
||||
nf_entrada: str
|
||||
aprovador: str
|
||||
valor_total: float
|
||||
parcelas: dict[int, Parcela]
|
||||
26
app/v1/scardua/modules/compras/service.py
Normal file
26
app/v1/scardua/modules/compras/service.py
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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:
|
||||
p = dados.properties
|
||||
return PayloadCompras(
|
||||
cnpj=p.cnpj,
|
||||
pedido_linx=p.pedido_linx,
|
||||
fornecedor=p.fornecedor,
|
||||
tipo=p.tipo_compra,
|
||||
valor_total=p.total_parcelas,
|
||||
parcelas=p.parcelas,
|
||||
)
|
||||
0
app/v1/scardua/modules/garantia/config.py
Normal file
0
app/v1/scardua/modules/garantia/config.py
Normal file
10
app/v1/scardua/modules/garantia/router.py
Normal file
10
app/v1/scardua/modules/garantia/router.py
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
from fastapi import APIRouter
|
||||
|
||||
from .schemas import DadosCompras
|
||||
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)
|
||||
5
app/v1/scardua/modules/garantia/schemas.py
Normal file
5
app/v1/scardua/modules/garantia/schemas.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from datetime import datetime
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
25
app/v1/scardua/modules/garantia/service.py
Normal file
25
app/v1/scardua/modules/garantia/service.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from .schemas import DadosCompras, PayloadCompras
|
||||
from .service import processar_compras
|
||||
from app.services.api_cliente 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:
|
||||
p = dados.properties
|
||||
return PayloadCompras(
|
||||
cnpj=p.cnpj,
|
||||
pedido_linx=p.pedido_linx,
|
||||
fornecedor=p.fornecedor,
|
||||
tipo=p.tipo_compra,
|
||||
valor_total=p.total_parcelas,
|
||||
parcelas=p.parcelas,
|
||||
)
|
||||
5
app/v1/scardua/scardua.py
Normal file
5
app/v1/scardua/scardua.py
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
from app.v1.scardua.modules.compras.router import router as compras
|
||||
from fastapi import APIRouter
|
||||
|
||||
holmes_router = APIRouter()
|
||||
holmes_router.include_router(compras)
|
||||
Loading…
Add table
Add a link
Reference in a new issue