""" Portal de recebimento de arte — roda na nuvem, aberto 24 horas. Fluxo: 0. Tiny avisa por webhook que nasceu um pedido de DTF 1. portal cria token e manda o link no WhatsApp 2. cliente abre o link e pede uma URL pré-assinada 3. o navegador envia o arquivo DIRETO para o storage (não passa por aqui) 4. cliente avisa que terminou -> robô valida, repete, fatia e carimba 5. o agente da fábrica busca o que foi aprovado Rodar: uvicorn main:app --host 0.0.0.0 --port 8000 """ from __future__ import annotations import os import uuid from datetime import datetime, timedelta, timezone import boto3 from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException from fastapi.responses import HTMLResponse from pydantic import BaseModel from sqlmodel import Field, Session, SQLModel, create_engine, select import preflight import tiny import whats # -------------------------------------------------------------------------- DB = os.environ["DATABASE_URL"] BUCKET = os.environ["S3_BUCKET"] BASE_PORTAL = os.environ["BASE_PORTAL"] # https://arte.dropstaratacado.com.br BASE_KANBAN = os.environ["BASE_KANBAN"] # http://servidor:8080 TOKEN_TINY = os.environ["WEBHOOK_TOKEN"] # segredo combinado com o Tiny TOKEN_AGENTE = os.environ["AGENTE_TOKEN"] VALIDADE_TOKEN_DIAS = 7 MAX_GB = 5 # limite do que os PCs da sala aguentam abrir MAX_ARQUIVOS = 10 engine = create_engine(DB) s3 = boto3.client("s3", endpoint_url=os.environ["S3_ENDPOINT"]) app = FastAPI(title="Portal de arte DTF") # -------------------------------------------------------------------------- # Tabelas # -------------------------------------------------------------------------- class Pedido(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) numero_tiny: str = Field(index=True, unique=True) cliente: str metros: float # metros comprados por arte token: str = Field(index=True, unique=True) token_expira: datetime criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class Arte(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) pedido_id: int = Field(foreign_key="pedido.id", index=True) arquivo: str bytes: int = 0 repeticoes: int = 1 status: str = "recebida" # recebida | aprovada | recusada motivo: str = "" avisos: str = "" dpi_efetivo: float = 0 largura_cm: float = 0 metros_totais: float = 0 baixada: bool = False criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc)) class Parte(SQLModel, table=True): id: int | None = Field(default=None, primary_key=True) arte_id: int = Field(foreign_key="arte.id", index=True) ordem: int metros: float arquivo: str carimbada: bool = False SQLModel.metadata.create_all(engine) def sessao(): with Session(engine) as s: yield s # -------------------------------------------------------------------------- # 0. Webhook do Tiny — pedido novo # -------------------------------------------------------------------------- class PedidoTiny(BaseModel): numero: str cliente: str telefone: str metros: float @app.post("/webhook/tiny") def pedido_novo( p: PedidoTiny, background: BackgroundTasks, x_token: str = Header(default=""), s: Session = Depends(sessao), ): if x_token != TOKEN_TINY: raise HTTPException(401, "token inválido") existente = s.exec(select(Pedido).where(Pedido.numero_tiny == p.numero)).first() if existente: return {"ok": True, "ja_existia": True} pedido = Pedido( numero_tiny=p.numero, cliente=p.cliente, metros=p.metros, token=uuid.uuid4().hex, token_expira=datetime.now(timezone.utc) + timedelta(days=VALIDADE_TOKEN_DIAS), ) s.add(pedido) s.commit() s.refresh(pedido) link = f"{BASE_PORTAL}/arte/{pedido.token}" background.add_task(whats.enviar_link, p.telefone, p.numero, link) return {"ok": True, "link": link} # -------------------------------------------------------------------------- # 1 e 2. Página e URL pré-assinada # -------------------------------------------------------------------------- def _pedido_por_token(token: str, s: Session) -> Pedido: p = s.exec(select(Pedido).where(Pedido.token == token)).first() if not p: raise HTTPException(404, "link inválido") if p.token_expira < datetime.now(timezone.utc): raise HTTPException(410, "link expirado — fale com o atendimento") return p @app.get("/arte/{token}", response_class=HTMLResponse) def pagina(token: str, s: Session = Depends(sessao)): _pedido_por_token(token, s) return open("static/portal.html", encoding="utf-8").read() @app.get("/api/arte/{token}") def dados(token: str, s: Session = Depends(sessao)): p = _pedido_por_token(token, s) return { "pedido": p.numero_tiny, "cliente": p.cliente, "metros": p.metros, "area_util_cm": preflight.AREA_UTIL_CM, "limite_arquivo_m": preflight.LIMITE_ARQUIVO_M, "max_gb": MAX_GB, } @app.post("/api/arte/{token}/url") def url_upload(token: str, nome: str, s: Session = Depends(sessao)): p = _pedido_por_token(token, s) n = len(s.exec(select(Arte).where(Arte.pedido_id == p.id)).all()) if n >= MAX_ARQUIVOS: raise HTTPException(400, f"máximo de {MAX_ARQUIVOS} arquivos por pedido") chave = f"{p.numero_tiny}/{uuid.uuid4().hex}_{nome}" url = s3.generate_presigned_url( "put_object", Params={"Bucket": BUCKET, "Key": chave}, ExpiresIn=3600, ) return {"url": url, "chave": chave} # -------------------------------------------------------------------------- # 4. Terminou o upload -> roda o pré-flight # -------------------------------------------------------------------------- class Pronto(BaseModel): chave: str repeticoes: int = 1 @app.post("/api/arte/{token}/pronto") def pronto( token: str, body: Pronto, background: BackgroundTasks, s: Session = Depends(sessao), ): p = _pedido_por_token(token, s) arte = Arte(pedido_id=p.id, arquivo=body.chave, repeticoes=max(1, body.repeticoes)) s.add(arte) s.commit() s.refresh(arte) background.add_task(processar, arte.id) return {"arte_id": arte.id, "status": "processando"} def processar(arte_id: int): """Baixa do storage, valida, repete, fatia, carimba e devolve as partes.""" with Session(engine) as s: arte = s.get(Arte, arte_id) pedido = s.get(Pedido, arte.pedido_id) local = f"/tmp/{arte.id}.png" s3.download_file(BUCKET, arte.arquivo, local) arte.bytes = os.path.getsize(local) r = preflight.processar( caminho=local, cm_comprados=pedido.metros * 100, repeticoes=arte.repeticoes, pedido=pedido.numero_tiny, base_kanban=BASE_KANBAN, pasta_saida="/tmp", ) arte.dpi_efetivo = r.dpi_efetivo arte.largura_cm = r.largura_cm arte.metros_totais = r.metros_totais arte.avisos = " | ".join(r.avisos) if not r.aprovado: arte.status, arte.motivo = "recusada", r.motivo s.add(arte) s.commit() whats.enviar_recusa(pedido, arte.motivo) return for i, caminho in enumerate(r.partes, start=1): chave = f"{pedido.numero_tiny}/partes/{os.path.basename(caminho)}" s3.upload_file(caminho, BUCKET, chave) s.add(Parte( arte_id=arte.id, ordem=i, metros=min(preflight.LIMITE_ARQUIVO_M, r.metros_totais - preflight.LIMITE_ARQUIVO_M * (i - 1)), arquivo=chave, carimbada=(i == len(r.partes)), )) arte.status = "aprovada" s.add(arte) s.commit() tiny.marcador(pedido.numero_tiny, "DTF-RECEBIDA") whats.enviar_aprovacao(pedido, r) avisar_agente() # -------------------------------------------------------------------------- # 5. O agente busca o que foi aprovado # -------------------------------------------------------------------------- @app.get("/api/artes") def para_baixar(x_token: str = Header(default=""), s: Session = Depends(sessao)): if x_token != TOKEN_AGENTE: raise HTTPException(401, "token inválido") artes = s.exec( select(Arte).where(Arte.status == "aprovada", Arte.baixada == False) # noqa: E712 ).all() saida = [] for a in artes: p = s.get(Pedido, a.pedido_id) partes = s.exec(select(Parte).where(Parte.arte_id == a.id)).all() saida.append({ "arte_id": a.id, "pedido": p.numero_tiny, "cliente": p.cliente, "metros": a.metros_totais, "partes": [ { "ordem": pt.ordem, "metros": pt.metros, "url": s3.generate_presigned_url( "get_object", Params={"Bucket": BUCKET, "Key": pt.arquivo}, ExpiresIn=3600, ), "nome": os.path.basename(pt.arquivo), } for pt in sorted(partes, key=lambda x: x.ordem) ], }) return saida @app.post("/api/artes/{arte_id}/baixada") def marcar_baixada( arte_id: int, x_token: str = Header(default=""), s: Session = Depends(sessao) ): if x_token != TOKEN_AGENTE: raise HTTPException(401, "token inválido") a = s.get(Arte, arte_id) a.baixada = True s.add(a) s.commit() return {"ok": True} def avisar_agente(): """Webhook para o agente na fábrica. O polling de 60s é a rede de segurança.""" import httpx url = os.environ.get("AGENTE_WEBHOOK") if not url: return try: httpx.post(url, timeout=5, headers={"x-token": TOKEN_AGENTE}) except Exception: pass # o polling pega @app.get("/saude") def saude(): return {"ok": True, "em": datetime.now(timezone.utc).isoformat()}