""" Agente da fábrica — traz a arte aprovada do portal para o servidor local. Roda como serviço: Windows: nssm install DtfAgente "C:\\Python312\\python.exe" "C:\\dtf\\agente.py" Linux: systemd (ver dtf-agente.service no repositório) Duas fontes de trabalho: - webhook do portal (chega na hora) - polling a cada 60 s (rede de segurança se o webhook falhar) Se a internet cair, o agente para e o portal continua recebendo. Quando voltar, ele baixa o acumulado. Nada se perde. """ from __future__ import annotations import hashlib import logging import os import sqlite3 import time from datetime import datetime from pathlib import Path import httpx PORTAL = os.environ["BASE_PORTAL"] TOKEN = os.environ["AGENTE_TOKEN"] RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF")) BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db")) INTERVALO = 60 HEARTBEAT = 300 PASTA_ENTRADA = RAIZ / "00_ARTE_RECEBIDA" logging.basicConfig( filename=RAIZ / "_log" / "agente.log", level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s", ) log = logging.getLogger("agente") def sha256(caminho: Path) -> str: h = hashlib.sha256() with open(caminho, "rb") as f: for bloco in iter(lambda: f.read(1 << 20), b""): h.update(bloco) return h.hexdigest() def registrar_no_kanban(arte: dict, arquivos: list[Path]) -> None: """Insere o card. Idempotente: a chave é o arte_id, não o nome do arquivo.""" con = sqlite3.connect(BANCO) try: con.execute(""" CREATE TABLE IF NOT EXISTS card( arte_id INTEGER PRIMARY KEY, pedido TEXT, cliente TEXT, metros REAL, coluna TEXT DEFAULT 'rec', desde TEXT, maquina TEXT, partes INTEGER )""") con.execute(""" INSERT OR IGNORE INTO card(arte_id,pedido,cliente,metros,desde,partes) VALUES (?,?,?,?,?,?)""", (arte["arte_id"], arte["pedido"], arte["cliente"], arte["metros"], datetime.now().isoformat(timespec="seconds"), len(arquivos))) con.commit() finally: con.close() def baixar(cliente: httpx.Client, arte: dict) -> bool: PASTA_ENTRADA.mkdir(parents=True, exist_ok=True) salvos: list[Path] = [] for parte in arte["partes"]: destino = PASTA_ENTRADA / f"{arte['pedido']}_{parte['nome']}" if destino.exists(): salvos.append(destino) continue tmp = destino.with_suffix(".parcial") with cliente.stream("GET", parte["url"]) as r: r.raise_for_status() with open(tmp, "wb") as f: for bloco in r.iter_bytes(1 << 20): f.write(bloco) tmp.rename(destino) # só aparece na pasta quando está completo salvos.append(destino) log.info("baixado %s (%s) sha=%s", destino.name, f"{parte['metros']:.2f} m", sha256(destino)[:12]) registrar_no_kanban(arte, salvos) cliente.post(f"{PORTAL}/api/artes/{arte['arte_id']}/baixada", headers={"x-token": TOKEN}, timeout=15) return True def ciclo(cliente: httpx.Client) -> int: r = cliente.get(f"{PORTAL}/api/artes", headers={"x-token": TOKEN}, timeout=30) r.raise_for_status() artes = r.json() for arte in artes: try: baixar(cliente, arte) except Exception as e: log.error("falha na arte %s: %s", arte["arte_id"], e) return len(artes) def main() -> None: log.info("agente iniciado · portal=%s · pasta=%s", PORTAL, RAIZ) ultimo_hb = 0.0 with httpx.Client(follow_redirects=True) as cliente: while True: try: n = ciclo(cliente) if n: log.info("%d arte(s) processada(s)", n) except httpx.HTTPError as e: # internet caiu: não é erro fatal, o portal segue recebendo log.warning("sem conexão com o portal: %s", e) except Exception as e: log.exception("erro inesperado: %s", e) agora = time.time() if agora - ultimo_hb > HEARTBEAT: try: cliente.post(f"{PORTAL}/api/agente/heartbeat", headers={"x-token": TOKEN}, timeout=10) ultimo_hb = agora except Exception: pass # sem sinal por 15 min, o portal alerta o TI time.sleep(INTERVALO) if __name__ == "__main__": main()