first commit
Some checks failed
Validate, publish and deploy / validate (push) Successful in 2m2s
Validate, publish and deploy / publish-and-deploy (push) Failing after 8s

This commit is contained in:
Cauê Faleiros
2026-09-15 16:42:34 -03:00
commit 98c951d374
170 changed files with 95988 additions and 0 deletions

322
portal/main.py Normal file
View File

@@ -0,0 +1,322 @@
"""
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()}

415
portal/preflight.py Normal file
View File

@@ -0,0 +1,415 @@
"""
Pré-flight do DTF: valida a arte, repete, fatia e carimba.
Regras acordadas com Marcus em 29/08/2026:
- unidade de venda: 57 x 100 cm
- faixa reservada no rodapé: 30 mm -> área útil 57 x 97 cm
- carimbo: QR de 20 mm + texto na mesma altura, só canal preto
- limite por arquivo gerado: 15 metros
- o carimbo vai apenas na ÚLTIMA parte do pedido
Dependências: pyvips, qrcode, pillow
apt install libvips-tools
pip install pyvips qrcode pillow
"""
from __future__ import annotations
import math
import os
import shutil
import subprocess
from dataclasses import dataclass, field
from datetime import datetime
import pyvips
import qrcode
# --------------------------------------------------------------------------
# Constantes do processo
# --------------------------------------------------------------------------
LARGURA_MAX_CM = 57.0
FAIXA_RODAPE_MM = 30.0 # reservada para o carimbo
ALTURA_UNIDADE_CM = 100.0
AREA_UTIL_CM = ALTURA_UNIDADE_CM - FAIXA_RODAPE_MM / 10 # 97 cm
DPI_MINIMO = 150 # abaixo disso recusa
DPI_IDEAL = 300 # entre 150 e 300 aceita com aviso
LIMITE_ARQUIVO_M = 20.0 # nenhum arquivo gerado passa disso
QR_MM = 20.0 # leitura por celular
ZONA_LIMPA_MM = 3.0 # margem branca ao redor do QR
FORMATOS_OK = {".png", ".tif", ".tiff", ".psd", ".psb", ".pdf", ".ai", ".svg", ".eps"}
# --------------------------------------------------------------------------
# Normalização — o cliente manda o que quiser, o designer recebe padronizado
# --------------------------------------------------------------------------
# Não existe "formato certo" único: normalizar tudo para um só jogaria fora o
# melhor de cada tipo. A regra é por NATUREZA do conteúdo.
#
# vetor -> PDF, mantendo vetor (rasterizar aqui é perder qualidade)
# pixel -> TIF com transparência (é o que a sala já usa)
#
# Isso resolve o vai-e-volta que os designers relatam hoje: abrir PDF no Corel
# para não rasterizar, exportar, abrir no Photoshop, e esbarrar no limite de
# 5 metros do PSD.
#
# O limite de 5 m NÃO é do Photoshop: é do formato PSD, que trava em 30.000 px
# por dimensão — a 150 DPI dá 5,08 m. PSB vai a 300.000 px, ou 50 metros.
# Em PDF vetorial não há esse limite; em TIF o teto é 4 GB por arquivo.
VETORIAIS = {".pdf", ".ai", ".svg", ".eps"}
RASTER = {".png", ".tif", ".tiff", ".psd", ".psb"}
FORMATOS_RECUSA = {".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
# CDR entra com etiqueta "conferir": pula o pré-flight e vai direto ao designer.
# A licença do CorelDRAW é de estação, não cobre servidor processando arquivo.
FORMATOS_SEM_VALIDACAO = {".cdr"}
CM_POR_POLEGADA = 2.54
# --------------------------------------------------------------------------
# Resultado
# --------------------------------------------------------------------------
@dataclass
class Resultado:
aprovado: bool
motivo: str = "" # mensagem ao cliente quando recusado
avisos: list[str] = field(default_factory=list)
dpi_efetivo: float = 0.0
largura_cm: float = 0.0
altura_cm: float = 0.0
metros_totais: float = 0.0
conferir_manual: bool = False # CDR: entra sem validar
natureza: str = "" # vetor | raster | conferir
partes: list[str] = field(default_factory=list) # caminhos gerados
# --------------------------------------------------------------------------
# 1. Validação
# --------------------------------------------------------------------------
def validar(caminho: str, cm_comprados: float) -> Resultado:
"""
cm_comprados = altura em centímetros que o cliente pagou por UMA arte.
Ex.: comprou 1 metro -> 100.
"""
r = Resultado(aprovado=False)
ext = os.path.splitext(caminho)[1].lower()
if ext in FORMATOS_SEM_VALIDACAO:
r.aprovado = True
r.conferir_manual = True
r.avisos.append("CDR não passa pelo pré-flight — vai direto para o designer.")
return r
if ext in FORMATOS_RECUSA:
r.motivo = (
f"Arquivo {ext.upper().lstrip('.')} não guarda transparência. "
"Envie PNG ou TIFF com fundo transparente."
)
return r
if ext not in FORMATOS_OK:
r.motivo = "Formato não aceito. Envie PNG ou TIFF com fundo transparente."
return r
try:
# access sequential: lê em streaming, não carrega 200 MB na memória
img = pyvips.Image.new_from_file(caminho, access="sequential")
except Exception:
r.motivo = "Não conseguimos abrir o arquivo. Ele pode estar corrompido."
return r
# --- canal alfa ---
if img.bands < 4 or not img.hasalpha():
r.motivo = (
"A arte está sem fundo transparente. Todo fundo não removido "
"sai impresso em branco na estampa."
)
return r
# --- DPI efetivo: a regra que mais pega arquivo ruim ---
# O DPI gravado no cabeçalho mente com frequência (o cliente escala a
# imagem e o programa mantém "300"). O que vale é pixel dividido pela
# medida que ele comprou.
area_util_cm = cm_comprados - FAIXA_RODAPE_MM / 10
r.dpi_efetivo = img.height / (area_util_cm / CM_POR_POLEGADA)
r.altura_cm = area_util_cm
r.largura_cm = img.width / r.dpi_efetivo * CM_POR_POLEGADA
if r.dpi_efetivo < DPI_MINIMO:
r.motivo = (
f"A resolução real da arte é de {r.dpi_efetivo:.0f} DPI no tamanho "
f"que você comprou. Precisamos de pelo menos {DPI_MINIMO} DPI — "
"o ideal é 300. Reenvie em maior resolução."
)
return r
if r.dpi_efetivo < DPI_IDEAL:
r.avisos.append(
f"Resolução de {r.dpi_efetivo:.0f} DPI. Funciona, mas detalhes "
"finos podem perder definição."
)
# --- largura física ---
if r.largura_cm > LARGURA_MAX_CM + 0.2: # 2 mm de tolerância
r.motivo = (
f"A arte tem {r.largura_cm:.1f} cm de largura. O máximo é "
f"{LARGURA_MAX_CM:.0f} cm. Use o gabarito 57 × 97 cm."
)
return r
# --- bordas com alfa parcial (sombras suaves) ---
alfa = img[img.bands - 1]
parcial = ((alfa > 10) & (alfa < 245)).avg() / 255.0
if parcial > 0.15:
r.avisos.append(
"A arte tem muitas bordas suaves. O branco de apoio pode sair "
"irregular nessas áreas."
)
r.aprovado = True
return r
# --------------------------------------------------------------------------
# 1b. Normalização
# --------------------------------------------------------------------------
def normalizar(caminho: str, pasta_saida: str, pedido: str) -> tuple[str, str]:
"""
Converte o que o cliente mandou para o formato de trabalho da sala.
Devolve (caminho_normalizado, natureza).
O ORIGINAL É SEMPRE PRESERVADO. Se a conversão sair ruim num caso
específico, o designer volta ao arquivo do cliente — pedido dos próprios
designers, e é barato perto de refazer o trabalho.
"""
ext = os.path.splitext(caminho)[1].lower()
if ext in FORMATOS_SEM_VALIDACAO: # CDR: sem Corel no servidor
return caminho, "conferir"
if ext in VETORIAIS:
# mantém vetor: nada de rasterizar. O FlexiPRINT rasteriza na resolução
# da máquina, na hora de imprimir — melhor que qualquer caminho manual.
destino = os.path.join(pasta_saida, f"{pedido}_norm.pdf")
if ext == ".pdf":
shutil.copy(caminho, destino)
else:
# AI, SVG e EPS convertem para PDF preservando o vetor
subprocess.run(
["inkscape", caminho, "--export-type=pdf",
f"--export-filename={destino}", "--export-text-to-path"],
check=True, capture_output=True,
)
return destino, "vetor"
# raster: TIF com alfa, sem compressão com perda
destino = os.path.join(pasta_saida, f"{pedido}_norm.tif")
img = pyvips.Image.new_from_file(caminho, access="sequential")
if not img.hasalpha():
img = img.bandjoin(255)
img = _para_rgb(img)
img.tiffsave(destino, compression="lzw", predictor="horizontal")
return destino, "raster"
def _para_rgb(img: pyvips.Image) -> pyvips.Image:
"""
CMYK vira RGB. Arquivo em CMYK é uma das causas de cor errada no DTF —
o cliente monta em CMYK, a máquina trabalha em RGB, e a cor muda.
"""
if img.interpretation in ("cmyk", "b-w"):
return img.colourspace("srgb")
return img
# --------------------------------------------------------------------------
# 2. Repetição
# --------------------------------------------------------------------------
def empilhar(caminho: str, repeticoes: int, destino: str) -> str:
"""Concatena N cópias da arte na vertical, sem espaço entre elas."""
if repeticoes <= 1:
return caminho
img = pyvips.Image.new_from_file(caminho, access="sequential")
empilhado = pyvips.Image.arrayjoin([img] * repeticoes, across=1)
empilhado.write_to_file(destino)
return destino
def montar_folha(caminhos: list[str], destino: str) -> str:
"""
Junta vários arquivos numa folha só, EMPILHADOS na ordem que subiram.
Decisão de 30/08/2026: empilhamento simples, sem encaixe lado a lado.
São artes de 1 metro em sequência — não é arte única e não há otimização
de largura. O cliente vê a prévia antes de fechar o pedido.
"""
if len(caminhos) == 1:
return caminhos[0]
imgs = [pyvips.Image.new_from_file(c, access="sequential") for c in caminhos]
largura = max(i.width for i in imgs)
# centraliza as artes mais estreitas, mantendo a largura da folha
ajustadas = [
i if i.width == largura
else i.embed((largura - i.width) // 2, 0, largura, i.height)
for i in imgs
]
pyvips.Image.arrayjoin(ajustadas, across=1).write_to_file(destino)
return destino
# --------------------------------------------------------------------------
# 3. Fatiamento
# --------------------------------------------------------------------------
def quantas_partes(metros_totais: float) -> int:
return max(1, math.ceil(metros_totais / LIMITE_ARQUIVO_M))
def fatiar(caminho: str, metros_totais: float, prefixo: str) -> list[str]:
"""
Corta em partes de no máximo 15 metros.
1 m x 20 -> 15 + 5 (2 arquivos)
100 m -> 15 x 6 + 10 (7 arquivos)
"""
n = quantas_partes(metros_totais)
if n == 1:
return [caminho]
img = pyvips.Image.new_from_file(caminho, access="random")
px_por_metro = img.height / metros_totais
partes, topo, restante = [], 0, metros_totais
for i in range(n):
m = min(LIMITE_ARQUIVO_M, restante)
altura = int(round(m * px_por_metro))
altura = min(altura, img.height - topo)
saida = f"{prefixo}_p{i + 1}.png"
img.crop(0, topo, img.width, altura).write_to_file(saida)
partes.append(saida)
topo += altura
restante -= m
return partes
# --------------------------------------------------------------------------
# 4. Carimbo
# --------------------------------------------------------------------------
def _qr_vips(conteudo: str, lado_px: int) -> pyvips.Image:
qr = qrcode.QRCode(
version=None,
error_correction=qrcode.constants.ERROR_CORRECT_M, # aguenta 15% de dano
box_size=1,
border=0,
)
qr.add_data(conteudo)
qr.make(fit=True)
m = qr.get_matrix()
n = len(m)
# matriz -> imagem 1 bit -> escala para o tamanho pedido
dados = bytes(0 if v else 255 for linha in m for v in linha)
img = pyvips.Image.new_from_memory(dados, n, n, 1, "uchar")
return img.resize(lado_px / n, kernel="nearest")
def carimbar(
caminho: str,
pedido: str,
metros: float,
url_kanban: str,
dpi: float = 300.0,
quando: datetime | None = None,
) -> str:
"""
Escreve o carimbo na faixa de 30 mm do rodapé.
QR de 20 mm com a URL do card + número, metragem e data ao lado.
Só canal preto — sem branco de apoio, para não gastar a tinta mais cara.
"""
quando = quando or datetime.now()
img = pyvips.Image.new_from_file(caminho, access="random")
px_mm = dpi / CM_POR_POLEGADA / 10
qr_px = int(QR_MM * px_mm)
margem = int(ZONA_LIMPA_MM * px_mm)
faixa_px = int(FAIXA_RODAPE_MM * px_mm)
# fundo branco só sob o QR, para o celular ler com contraste
fundo = pyvips.Image.black(qr_px + margem * 2, qr_px + margem * 2) + 255
qr = _qr_vips(url_kanban, qr_px)
bloco = fundo.insert(qr, margem, margem)
# texto alinhado à altura do QR
texto = (
f"<span size='{int(qr_px*0.28)*1000}' weight='bold'>{pedido}</span>\n"
f"<span size='{int(qr_px*0.19)*1000}'>{metros:.2f} m</span>\n"
f"<span size='{int(qr_px*0.15)*1000}' foreground='#555555'>"
f"{quando:%d/%m · %H:%M}</span>"
).replace(".", ",")
txt = pyvips.Image.text(texto, dpi=int(dpi), align="low")
# base da faixa: transparente, o carimbo entra em preto
base = pyvips.Image.black(img.width, faixa_px, bands=4)
y_qr = max(0, (faixa_px - bloco.height) // 2)
base = base.insert(bloco.bandjoin(255), margem, y_qr, expand=False)
base = base.insert(
txt.bandjoin(255) if txt.bands < 4 else txt,
margem * 2 + bloco.width,
max(0, (faixa_px - txt.height) // 2),
expand=False,
)
final = pyvips.Image.arrayjoin([img, base], across=1)
saida = caminho.replace(".png", "_carimbado.png")
final.write_to_file(saida)
return saida
# --------------------------------------------------------------------------
# 5. Orquestração — é isso que o portal chama
# --------------------------------------------------------------------------
def processar(
caminho: str,
cm_comprados: float,
repeticoes: int,
pedido: str,
base_kanban: str,
pasta_saida: str,
) -> Resultado:
# 0. normaliza antes de tudo: o designer nunca mais converte à mão
caminho, natureza = normalizar(caminho, pasta_saida, pedido)
if natureza == "conferir":
r = Resultado(aprovado=True, conferir_manual=True)
r.avisos.append("CDR: sem validação automática, vai direto ao designer.")
r.partes = [caminho]
return r
r = validar(caminho, cm_comprados)
r.natureza = natureza
if not r.aprovado:
return r
r.metros_totais = cm_comprados / 100 * repeticoes
prefixo = os.path.join(pasta_saida, pedido)
empilhado = empilhar(caminho, repeticoes, f"{prefixo}_full.png")
partes = fatiar(empilhado, r.metros_totais, prefixo)
# o carimbo vai só na última parte: é ela que fecha o pedido
partes[-1] = carimbar(
partes[-1],
pedido=pedido,
metros=r.metros_totais,
url_kanban=f"{base_kanban}/p/{pedido}",
dpi=r.dpi_efetivo,
)
r.partes = partes
return r
if __name__ == "__main__":
# conferência rápida da regra de fatiamento
for m in (20, 15, 16, 45, 100):
print(f"{m:>4} m -> {quantas_partes(m)} arquivo(s)")

89
portal/tiny.py Normal file
View File

@@ -0,0 +1,89 @@
"""
Cliente da API do Tiny (Olist) — v3, OAuth2 client credentials.
ATENÇÃO Wagner: confirmar na documentação oficial antes de subir:
- o endpoint exato de marcadores do pedido
- o limite de requisições por minuto (dimensiona o worker)
- se o refresh token expira e com que frequência
Este módulo isola a API: se o endpoint mudar, muda só aqui.
"""
from __future__ import annotations
import os
import time
import httpx
BASE = os.environ.get("TINY_BASE", "https://api.tiny.com.br/public-api/v3")
CLIENT_ID = os.environ["TINY_CLIENT_ID"]
CLIENT_SECRET = os.environ["TINY_CLIENT_SECRET"]
TOKEN_URL = os.environ["TINY_TOKEN_URL"]
_token: dict = {"valor": None, "expira": 0.0}
def _acesso() -> str:
if _token["valor"] and time.time() < _token["expira"] - 60:
return _token["valor"]
r = httpx.post(TOKEN_URL, data={
"grant_type": "client_credentials",
"client_id": CLIENT_ID,
"client_secret": CLIENT_SECRET,
}, timeout=20)
r.raise_for_status()
d = r.json()
_token["valor"] = d["access_token"]
_token["expira"] = time.time() + d.get("expires_in", 3600)
return _token["valor"]
def _headers() -> dict:
return {"Authorization": f"Bearer {_acesso()}", "Content-Type": "application/json"}
def buscar_pedido(numero: str) -> dict:
r = httpx.get(f"{BASE}/pedidos", params={"numero": numero},
headers=_headers(), timeout=20)
r.raise_for_status()
itens = r.json().get("itens") or []
if not itens:
raise LookupError(f"pedido {numero} não encontrado no Tiny")
return itens[0]
def transferido_para_deposito(sku: str, deposito: str, dias: int) -> float:
"""
Soma o que foi transferido para o depósito de impressão no período.
A Altus já faz essa transferência hoje, porque também revende insumo — o
depósito separa consumo interno de revenda. Por isso o aproveitamento sai
de graça: nenhum apontamento novo na sala.
ATENÇÃO Wagner: confirmar o endpoint de movimentações de estoque por
depósito na API v3 e o nome exato do depósito no cadastro.
"""
r = httpx.get(
f"{BASE}/estoque/movimentacoes",
params={"codigo": sku, "deposito": deposito, "dias": dias, "tipo": "E"},
headers=_headers(), timeout=30,
)
r.raise_for_status()
return sum(float(m.get("quantidade", 0)) for m in r.json().get("itens", []))
def marcador(numero: str, marcador: str) -> None:
"""
Troca o marcador do pedido. Substitui os marcadores de etapa do DTF,
preservando marcadores de outra natureza (multiempresa, VALE, Troca...).
"""
pedido = buscar_pedido(numero)
atuais = [m["descricao"] for m in pedido.get("marcadores", [])]
# só três marcadores de etapa vivem no Tiny; o resto do fluxo é do kanban
ETAPAS = {"DTF-RECEBIDA", "DTF-PRODUCAO", "CORRECAO DTF", "DTF-PRONTO"}
mantem = [m for m in atuais if m not in ETAPAS]
novos = mantem + [marcador]
r = httpx.put(f"{BASE}/pedidos/{pedido['id']}/marcadores",
json={"marcadores": [{"descricao": m} for m in novos]},
headers=_headers(), timeout=20)
r.raise_for_status()

68
portal/whats.py Normal file
View File

@@ -0,0 +1,68 @@
"""
Envio de WhatsApp. Três avisos ao cliente, não sete:
aprovada · em produção · finalizada (+ correção, a única que pede resposta)
Provedor definido por WHATS_PROVEDOR = meta | zapi
A API oficial da Meta exige template aprovado para mensagem iniciada por nós.
"""
from __future__ import annotations
import os
import httpx
PROVEDOR = os.environ.get("WHATS_PROVEDOR", "meta")
TOKEN = os.environ.get("WHATS_TOKEN", "")
NUMERO_ID = os.environ.get("WHATS_NUMERO_ID", "")
ZAPI_URL = os.environ.get("ZAPI_URL", "")
TEXTOS = {
"prova_cor": ("Antes de imprimir o pedido {pedido} inteiro, fizemos uma amostra "
"de cor. Confira a foto e responda APROVO para seguirmos. A cor no "
"filme pode variar em relação ao seu monitor."),
"producao": "Seu pedido {pedido} entrou em produção. Avisamos quando ficar pronto.",
"concluido": "Pedido {pedido} finalizado e pronto para retirada ou envio.",
"correcao": "Precisamos de um ajuste na arte do pedido {pedido}: {motivo}",
}
def _enviar(telefone: str, texto: str) -> None:
if PROVEDOR == "zapi":
httpx.post(ZAPI_URL, json={"phone": telefone, "message": texto}, timeout=20)
return
httpx.post(
f"https://graph.facebook.com/v20.0/{NUMERO_ID}/messages",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"messaging_product": "whatsapp", "to": telefone,
"type": "text", "text": {"body": texto}},
timeout=20,
).raise_for_status()
def enviar_link(telefone: str, pedido: str, link: str) -> None:
_enviar(telefone,
f"Pedido {pedido} confirmado. Envie sua arte por aqui: {link}\n"
"O link vale por 7 dias. Gabarito 57 × 97 cm, PNG 300 DPI com fundo "
"transparente.")
def enviar_aprovacao(pedido, r) -> None:
n = len(r.partes)
_enviar(pedido.telefone if hasattr(pedido, "telefone") else "",
f"Arte do pedido {pedido.numero_tiny} aprovada. "
f"{r.metros_totais:.2f} m em {n} arquivo{'s' if n > 1 else ''}. "
"Entrou na fila de impressão.".replace(".", ",", 1))
def enviar_recusa(pedido, motivo: str) -> None:
_enviar(getattr(pedido, "telefone", ""),
f"A arte do pedido {pedido.numero_tiny} precisa de ajuste: {motivo}\n"
"O prazo só começa a contar depois que a arte for aprovada.")
def avisar(pedido: str, tipo: str, motivo: str = "") -> None:
# o telefone vem do cadastro do pedido no Tiny
import tiny
p = tiny.buscar_pedido(pedido)
telefone = (p.get("cliente") or {}).get("fone", "")
_enviar(telefone, TEXTOS[tipo].format(pedido=pedido, motivo=motivo))