first commit
This commit is contained in:
322
portal/main.py
Normal file
322
portal/main.py
Normal 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
415
portal/preflight.py
Normal 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
89
portal/tiny.py
Normal 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
68
portal/whats.py
Normal 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))
|
||||
Reference in New Issue
Block a user