323 lines
10 KiB
Python
323 lines
10 KiB
Python
"""
|
|
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()}
|