From ca698434a24fcb62488d50dac60bfde3cb73c0c6 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Cau=C3=AA=20Faleiros?= Date: Mon, 21 Sep 2026 16:34:20 -0300 Subject: [PATCH] chore: remove the abandoned prototypes and archive what described them MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit portal/, kanban/ and agente/ were 2,034 lines implementing the original Tiny-first model: token upload links, a second SQLite Kanban, a factory agent. Nothing imported or started any of it, and several endpoints took the acting user from the request body with no authentication at all. Their real cost was that a reader arriving at this repository found two Kanbans and two portals and had to work out which one was real. The root schema.sql and .env.exemplo went with them: both code paths load local/schema.sql, and having .env.exemplo beside .env.example differing by one letter was a trap rather than a convenience. The documents describing that model are archived rather than deleted. They record decisions and reasoning the current documents do not repeat, so they are worth keeping as background, with a header saying plainly that they are not instructions. README.md keeps its business case — the capacity figures and the cost argument are still the reason this project exists — but now states where the prototype documentation begins and that the code it describes is gone. Co-Authored-By: Claude Opus 5 --- .env.exemplo | 27 - CONTEXT.md | 20 +- README.md | 10 + agente/agente.py | 141 ----- agente/dtf-agente.service | 14 - API.md => docs/historico/API.md | 0 .../historico/ESPECIFICACAO.md | 0 .../historico/IMPLEMENTATION_REPORT.md | 0 docs/historico/README.md | 26 + TAREFAS.md => docs/historico/TAREFAS.md | 0 .../historico/dtf-organograma-sistema.pdf | Bin .../historico/dtf-organograma-ti.html | 0 kanban/main.py | 557 ------------------ kanban/static/kanban.html | 271 --------- kanban/tiny.py | 89 --- kanban/whats.py | 68 --- portal/main.py | 322 ---------- portal/preflight.py | 415 ------------- portal/tiny.py | 89 --- portal/whats.py | 68 --- schema.sql | 222 ------- 21 files changed, 47 insertions(+), 2292 deletions(-) delete mode 100644 .env.exemplo delete mode 100644 agente/agente.py delete mode 100644 agente/dtf-agente.service rename API.md => docs/historico/API.md (100%) rename ESPECIFICACAO.md => docs/historico/ESPECIFICACAO.md (100%) rename IMPLEMENTATION_REPORT.md => docs/historico/IMPLEMENTATION_REPORT.md (100%) create mode 100644 docs/historico/README.md rename TAREFAS.md => docs/historico/TAREFAS.md (100%) rename dtf-organograma-sistema.pdf => docs/historico/dtf-organograma-sistema.pdf (100%) rename dtf-organograma-ti.html => docs/historico/dtf-organograma-ti.html (100%) delete mode 100644 kanban/main.py delete mode 100644 kanban/static/kanban.html delete mode 100644 kanban/tiny.py delete mode 100644 kanban/whats.py delete mode 100644 portal/main.py delete mode 100644 portal/preflight.py delete mode 100644 portal/tiny.py delete mode 100644 portal/whats.py delete mode 100644 schema.sql diff --git a/.env.exemplo b/.env.exemplo deleted file mode 100644 index e246ae9..0000000 --- a/.env.exemplo +++ /dev/null @@ -1,27 +0,0 @@ -# HISTORICAL PROTOTYPE ONLY. Do not use for the local stack; use .env.example. -# ---- portal (nuvem) ---- -DATABASE_URL=postgresql+psycopg://dtf:senha@localhost/dtf -S3_ENDPOINT=https://.r2.cloudflarestorage.com -S3_BUCKET=dtf-artes -AWS_ACCESS_KEY_ID= -AWS_SECRET_ACCESS_KEY= -BASE_PORTAL=https://arte.dropstaratacado.com.br -BASE_KANBAN=http://servidor:8080 -WEBHOOK_TOKEN=troque-isto -AGENTE_TOKEN=troque-isto-tambem -AGENTE_WEBHOOK=http://ip-da-fabrica:8080/api/agente/acordar - -# ---- Tiny ---- -TINY_CLIENT_ID= -TINY_CLIENT_SECRET= -TINY_TOKEN_URL= -TINY_BASE=https://api.tiny.com.br/public-api/v3 - -# ---- WhatsApp ---- -WHATS_PROVEDOR=meta -WHATS_TOKEN= -WHATS_NUMERO_ID= - -# ---- fábrica ---- -PASTA_DTF=\\servidor\DTF -KANBAN_DB=C:\dtf\kanban.db diff --git a/CONTEXT.md b/CONTEXT.md index a1e3ccf..8e25349 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -4,7 +4,7 @@ Read this document before changing code, documentation, architecture, or scope. It is the current English source of truth for the project. It supersedes older -decisions in `README.md`, `ESPECIFICACAO.md`, `schema.sql`, and the existing +decisions in `README.md`, `docs/historico/ESPECIFICACAO.md`, `schema.sql`, and the existing prototype code whenever they conflict. Update this file whenever the team makes a material product, process, or @@ -340,7 +340,7 @@ as references and are not imported or started by Compose. activation remain incomplete. Deployment plumbing exists but cannot pass its release gate yet. No factory automation or pre-flight is added. -See `IMPLEMENTATION_REPORT.md` for verification, `PRODUCTION_INPUTS.md` for the +See `docs/historico/IMPLEMENTATION_REPORT.md` for verification, `PRODUCTION_INPUTS.md` for the decisions and evidence required before staging or production connection, and `PORTAINER.md` for the production delivery contract. The client roadmap promises are unchanged, so its PDF source is not regenerated @@ -348,13 +348,15 @@ for this local implementation checkpoint. ### Existing assets -- `dtf-site.html`: rich static front-end prototype. It includes the current - commercial rules, client-side artwork analysis, current-session cart, and - delivery UI. The localhost checkout bridge connects it to the new local API. -- `portal/`: early FastAPI prototype for the original Tiny-first, token-link - upload flow. -- `kanban/`: early FastAPI/SQLite Kanban prototype. -- `agente/`: factory-side agent prototype; out of current MVP scope. +- `dtf-site.html`: the Site. Commercial rules, artwork analysis, cart and + delivery UI; its behaviour lives in `local/static/site-*.js`. +- `docs/historico/`: the original specification, endpoint sketch, task plan and + status report. Background only — they describe a model this system does not + implement. + +The prototypes they describe (`portal/`, `kanban/`, `agente/`, the root +`schema.sql` and `.env.exemplo`) were removed on 2026-09-21 once nothing +referenced them; recover from Git history if ever needed. - `tmp/pdfs/generate_dtf_report.py`: generator for the current client-facing roadmap PDF. - `output/pdf/dtf-plano-producao-e-roadmap.pdf`: current generated roadmap. diff --git a/README.md b/README.md index 57885ff..bbc959f 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,16 @@ > a arquitetura, o código e o que ainda depende de resposta. > Base: conversas de 29 e 30/08/2026 com Marcus. + +> **2026-09-21.** Everything below this line is the original prototype +> documentation. The code it describes (`portal/`, `kanban/`, `agente/`, the root +> `schema.sql`, `.env.exemplo`) no longer exists, and the model it describes is +> not the one implemented. Kept for the business reasoning in it — the capacity +> figures, the cost argument, the meeting decisions. For how the system actually +> works read [CONTEXT.md](CONTEXT.md); for what is outstanding read +> [ROADMAP.md](ROADMAP.md); for the original documents see +> [docs/historico/](docs/historico/README.md). + --- ## Por que este projeto existe diff --git a/agente/agente.py b/agente/agente.py deleted file mode 100644 index 1fb03bf..0000000 --- a/agente/agente.py +++ /dev/null @@ -1,141 +0,0 @@ -""" -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() diff --git a/agente/dtf-agente.service b/agente/dtf-agente.service deleted file mode 100644 index 9dbd3ab..0000000 --- a/agente/dtf-agente.service +++ /dev/null @@ -1,14 +0,0 @@ -[Unit] -Description=Agente DTF - baixa artes do portal -After=network-online.target - -[Service] -Type=simple -User=dtf -EnvironmentFile=/etc/dtf/.env -ExecStart=/usr/bin/python3 /opt/dtf/agente.py -Restart=always -RestartSec=15 - -[Install] -WantedBy=multi-user.target diff --git a/API.md b/docs/historico/API.md similarity index 100% rename from API.md rename to docs/historico/API.md diff --git a/ESPECIFICACAO.md b/docs/historico/ESPECIFICACAO.md similarity index 100% rename from ESPECIFICACAO.md rename to docs/historico/ESPECIFICACAO.md diff --git a/IMPLEMENTATION_REPORT.md b/docs/historico/IMPLEMENTATION_REPORT.md similarity index 100% rename from IMPLEMENTATION_REPORT.md rename to docs/historico/IMPLEMENTATION_REPORT.md diff --git a/docs/historico/README.md b/docs/historico/README.md new file mode 100644 index 0000000..a4e45ef --- /dev/null +++ b/docs/historico/README.md @@ -0,0 +1,26 @@ +# Historical documents + +These describe the project as it was originally designed: a Tiny-first flow with +token upload links, a factory-side agent, a local SQLite Kanban, FlexiPRINT +automation, 90-day retention and 12-month artwork reuse. + +**None of it describes the system that exists.** `CONTEXT.md` is the current +source of truth and `ROADMAP.md` tracks outstanding work. These are kept because +they record decisions and reasoning that the current documents do not repeat — +read them for background, never as instructions. + +| File | What it was | +|---|---| +| `ESPECIFICACAO.md` | Original specification of the Tiny-first model | +| `API.md` | Endpoint sketch for the prototype portal | +| `TAREFAS.md` | Task plan written before the current scope | +| `IMPLEMENTATION_REPORT.md` | Status report for the prototype milestone | +| `dtf-organograma-*` | Organisation charts, referenced by nothing | + +The prototype code they describe — `portal/`, `kanban/`, `agente/`, the root +`schema.sql` and `.env.exemplo` — was removed once nothing referenced it. Recover +it from Git history if it is ever needed: + +```bash +git log --oneline --diff-filter=D -- portal kanban agente +``` diff --git a/TAREFAS.md b/docs/historico/TAREFAS.md similarity index 100% rename from TAREFAS.md rename to docs/historico/TAREFAS.md diff --git a/dtf-organograma-sistema.pdf b/docs/historico/dtf-organograma-sistema.pdf similarity index 100% rename from dtf-organograma-sistema.pdf rename to docs/historico/dtf-organograma-sistema.pdf diff --git a/dtf-organograma-ti.html b/docs/historico/dtf-organograma-ti.html similarity index 100% rename from dtf-organograma-ti.html rename to docs/historico/dtf-organograma-ti.html diff --git a/kanban/main.py b/kanban/main.py deleted file mode 100644 index a8da5e8..0000000 --- a/kanban/main.py +++ /dev/null @@ -1,557 +0,0 @@ -""" -Kanban da sala de DTF — roda no servidor da fábrica, rede interna. - -Princípio: o KANBAN é a única interface. O operador move o card aqui e as -pastas do servidor seguem sozinhas — ninguém abre o Explorer. -Decidido em 30/08/2026: não existe watchdog de pastas. Movimento feito fora do -kanban não é suportado. - -A fila é sempre por HORÁRIO DE CHEGADA: quem chegou primeiro fica em cima. -Mover o card grava o movimento, move o arquivo, enfileira o marcador no Tiny -e o WhatsApp ao cliente. Ninguém abre o Tiny para atualizar nada. - -O log de movimentos é gravado SEMPRE, mesmo se o Tiny estiver fora do ar. -A medição de tempo por etapa nunca depende de API de terceiro. - -Rodar: - uvicorn main:app --host 0.0.0.0 --port 8080 -""" - -from __future__ import annotations - -import json -import os -import shutil -import sqlite3 -import threading -import time -from datetime import datetime, timedelta -from pathlib import Path - -from fastapi import FastAPI, HTTPException -from fastapi.responses import FileResponse, RedirectResponse -from pydantic import BaseModel - -import tiny -import whats - -BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db")) -RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF")) - -# O KANBAN é a fonte de verdade da operação. O marcador no Tiny existe só para -# quem NÃO abre o kanban: comercial atendendo cliente no telefone, expedição -# conferindo se o DTF saiu, e o Marcus olhando pelo celular fora da fábrica. -# -# Por isso o Tiny recebe o estágio grosso, não o detalhe fino: -# está sendo feito · travou · ficou pronto -# -# Três chamadas por pedido em vez de sete. A 213 pedidos/dia, são 640 requisições -# em vez de 1.500 — folga no limite da API e menos job para o TI monitorar. -COLUNAS = [ - ("aut", "Aguardando autorização", "05_AUTORIZACAO", None, False), # só retrabalho - ("rec", "Arte recebida", "00_ARTE_RECEBIDA", None, False), - ("tra", "Arte tratada", "10_ARTE_TRATADA", None, False), - ("apc", "Aprovação de cor", "15_APROVACAO_COR", "DTF-PROVA-COR",False), - ("fil", "Fila de impressão", "20_FILA_IMPRESSAO", None, False), - ("imp", "Imprimindo", "30_IMPRIMINDO", "DTF-PRODUCAO", False), - ("cor", "Correção", "40_CORRECAO", "CORRECAO DTF", False), - ("fin", "Finalizado", "60_FINALIZADO", "DTF-PRONTO", False), -] -# A sala só IMPRIME o filme — quem aplica na peça é o cliente. -# Por isso não existe coluna de aplicação nem causa de retrabalho por aplicação. -IDS = [c[0] for c in COLUNAS] -INFO = {c[0]: c for c in COLUNAS} -AVISA_CLIENTE = {"apc": "prova_cor", "imp": "producao", - "cor": "correcao", "fin": "concluido"} - -app = FastAPI(title="Kanban DTF") - -# --------------------------------------------------------------------------- -# Distribuição entre as máquinas — POR PUXADA, não por empurro -# --------------------------------------------------------------------------- -# O sistema NÃO decide qual máquina roda qual pedido. A fila é única, por -# horário de chegada, e a máquina que termina puxa o próximo. -# -# Empurrar exigiria adivinhar qual máquina estará livre daqui a duas horas. Se -# uma travar, a fila dela para enquanto outra fica ociosa, e alguém remaneja na -# mão. Por puxada nunca desbalanceia. -# -# Para o operador: um botão "puxar próximo". O arquivo já vem com o spot pronto -# do robô — ele só abre no Flexi e roda. -# Ajustado em 02/09/2026 pelo Thales e Alexandre: -# "reserva de 15 min é demais, no máx 3 min" -# "pedido por vez" — não puxar lote de trabalho -RESERVA_MIN = 3 # sem entrar em Imprimindo nesse prazo, volta para a fila -PUXAR_ATE_MIN = 0 # 0 = um pedido por vez, como a sala pediu - - -class Puxar(BaseModel): - maquina: int - usuario: str - minutos: int = PUXAR_ATE_MIN - - -@app.post("/api/puxar") -def puxar(p: Puxar): - """ - A máquina puxa o próximo da fila. Reserva por 15 min e move o arquivo para - a hot folder daquela máquina — o FlexiPRINT processa sozinho de lá. - """ - agora = datetime.now() - limite = (agora - timedelta(minutes=RESERVA_MIN)).isoformat(timespec="seconds") - pegos, minutos = [], 0 - - with con() as c: - # solta reservas vencidas antes de distribuir - c.execute("UPDATE card SET reservado_por=NULL, reservado_em=NULL " - "WHERE reservado_em IS NOT NULL AND reservado_em < ?", (limite,)) - - fila = c.execute( - "SELECT * FROM card WHERE coluna='fil' AND reservado_por IS NULL " - "ORDER BY desde").fetchall() # sempre por horário de chegada - - for card in fila: - # estimativa de máquina: o campo do card manda; sem ele, calcula pelos metros - mm = card["minutos_maquina"] or round(card["metros"] / 20 * 60) - if pegos: # um pedido por vez - break - c.execute("UPDATE card SET reservado_por=?, reservado_em=?, maquina=? " - "WHERE arte_id=?", - (f"Maq {p.maquina}", agora.isoformat(timespec="seconds"), - f"Maq {p.maquina}", card["arte_id"])) - mover_arquivos(card["pedido"], "20_FILA_IMPRESSAO", f"30_MAQ{p.maquina}") - pegos.append({"pedido": card["pedido"], "metros": card["metros"], - "minutos": mm}) - minutos += mm - - return {"maquina": f"Maq {p.maquina}", "pedidos": pegos, - "minutos_total": minutos, - "reserva_expira_em": RESERVA_MIN} - - -@app.post("/api/devolver") -def devolver(arte_id: int, usuario: str): - """ - Máquina travou no meio. O pedido volta ao TOPO da fila, não ao fim — - ele já esperou uma vez. - """ - with con() as c: - card = c.execute("SELECT * FROM card WHERE arte_id=?", (arte_id,)).fetchone() - if not card: - raise HTTPException(404) - maq = (card["maquina"] or "Maq 1").replace("Maq ", "") - mover_arquivos(card["pedido"], f"30_MAQ{maq}", "20_FILA_IMPRESSAO") - # desde antigo = volta para o topo da ordem por horário de chegada - c.execute("UPDATE card SET coluna='fil', reservado_por=NULL, " - "reservado_em=NULL, maquina=NULL WHERE arte_id=?", (arte_id,)) - c.execute("""INSERT INTO movimento - (arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina) - VALUES (?,?,?,?,?,?,?,?,?)""", - (arte_id, card["pedido"], "imp", "fil", card["desde"], - datetime.now().isoformat(timespec="seconds"), 0, usuario, card["maquina"])) - return {"ok": True, "voltou_ao_topo": True} - - -# --------------------------------------------------------------------------- -# Aprovação de cor — evita o retrabalho em vez de repor depois -# --------------------------------------------------------------------------- -# Imprime uma amostra pequena, fotografa e manda ao cliente. Ele aprova, e só -# então o pedido vai para a fila. Custa centímetros de filme e evita metros. -# -# Dispara sozinho em três situações: -PROVA_METROS = 10.0 # arquivo grande: erro de cor custa caro -PROVA_CLIENTE_NOVO = True # primeiro pedido do cliente -# cores fora do gamut CMYK, que quase nunca saem como o cliente vê na tela -PROVA_CORES_TENSAS = { - "vermelho_saturado", "laranja", "verde_vivo", "azul_royal", - "roxo", "tom_de_pele", "cinza_neutro", -} - - -def precisa_prova_cor(metros: float, cliente_novo: bool, - cores_detectadas: set[str]) -> tuple[bool, str]: - if metros >= PROVA_METROS: - return True, f"arquivo de {metros:.1f} m" - if cliente_novo and PROVA_CLIENTE_NOVO: - return True, "primeiro pedido do cliente" - tensas = cores_detectadas & PROVA_CORES_TENSAS - if tensas: - return True, "cor fora do gamut: " + ", ".join(sorted(tensas)) - return False, "" - -# --------------------------------------------------------------------------- -# Retrabalho — SKU CRRMP.TX.100CM -# --------------------------------------------------------------------------- -# A causa é obrigatória e define quem absorve o custo. Sem ela o card não anda. -CAUSAS = { - "arte_cliente": ("Arte ruim aprovada pelo cliente", "cliente"), - "tratamento": ("Erro de tratamento", "casa"), - "impressao": ("Falha de impressão", "casa"), - "perfil_cor": ("Perfil de cor", "casa"), - "pedido": ("Erro de pedido", "casa"), -} - -# Quem abre e quem autoriza são pessoas diferentes, de propósito. -# -# MAYANA abre e CLASSIFICA a causa. Ela conhece o cliente e sabe se a -# reclamação procede. A causa fica TRAVADA depois de aberta. -# -# THALES ou ALEXANDRE autorizam. O retrabalho da casa entra na meta e na -# remuneração deles — então têm incentivo real para questionar o que não -# procede. É controle natural, não burocracia. -# -# Por que a causa não pode ser mudada por quem autoriza: com o indicador -# atrelado a bônus, existiria incentivo para reclassificar falha de máquina -# como "arte do cliente". Quem discorda CONTESTA, e a contestação fica -# registrada com quem pediu e quem decidiu. -ABRE = "mayana" -AUTORIZA = ("thales", "alexandre") - -# Alçada por metragem. Cada metro custa R$ 4,94 de insumo mais tempo de máquina. -ALCADA = [(15.0, "sala"), (float("inf"), "financeiro")] -# Meta por causa, não meta única. Cada um responde pelo que controla. -# 0,4% em cada uma das quatro causas da casa = 1,6% no total. -# -# Perfil de cor é causa PRÓPRIA, separada de falha de impressão. Cor errada é o -# retrabalho mais comum de DTF e quase nunca é defeito de máquina: ou a arte -# veio em CMYK, ou o monitor do cliente não é calibrado, ou o perfil da -# impressora está desatualizado. Só o terceiro é da casa — e separando dá para -# saber quanto é cada coisa. -META_POR_CAUSA = { - "impressao": (0.4, "Thales e Alexandre"), - "perfil_cor": (0.4, "Thales e Alexandre"), - "tratamento": (0.4, "designers"), - "pedido": (0.4, "comercial"), - "arte_cliente": (None, None), # reposição comercial: fora da meta -} -# soma das metas da casa = 1,6% -TETO_MES_PCT = 6.0 # acima disso tudo sobe uma alçada -TETO_CLIENTE_PCT = 10.0 # cliente acima disso fica sinalizado no painel - - -def quem_autoriza(metros: float, vez: int, pct_mes: float) -> str: - """Reincidência e teto do mês sobem a alçada automaticamente.""" - if vez >= 3: - return "diretoria" - nivel = next(n for lim, n in ALCADA if metros <= lim) - if vez == 2: - nivel = {"sala": "financeiro"}.get(nivel, "diretoria") - if pct_mes > TETO_MES_PCT: - nivel = {"sala": "financeiro", "financeiro": "diretoria"}.get(nivel, "diretoria") - return nivel - - -def conta_na_meta(causa: str) -> bool: - """Só o retrabalho da casa entra na meta. O do cliente, não.""" - return CAUSAS[causa][1] == "casa" - - -@app.get("/api/relatorio/retrabalho") -def retrabalho(dias: int = 30): - """ - Retrabalho por causa, com responsável e meta. - Base para a bonificação: cada equipe é medida só pelo que controla. - """ - with con() as c: - total = c.execute( - "SELECT COUNT(*) n FROM card WHERE desde >= date('now', ?)", - (f"-{dias} days",)).fetchone()["n"] or 1 - linhas = c.execute(""" - SELECT causa, COUNT(*) n, SUM(metros) m - FROM retrabalho WHERE aberto_em >= date('now', ?) - GROUP BY causa""", (f"-{dias} days",)).fetchall() - - saida, casa = [], 0.0 - for r in linhas: - pct = r["n"] / total * 100 - meta, quem = META_POR_CAUSA.get(r["causa"], (None, None)) - if conta_na_meta(r["causa"]): - casa += pct - saida.append({ - "causa": r["causa"], "descricao": CAUSAS[r["causa"]][0], - "responsavel": quem, "pedidos": r["n"], "metros": r["m"], - "pct": round(pct, 2), "meta": meta, - "bate": None if meta is None else pct <= meta, - }) - return {"por_causa": saida, "total_casa_pct": round(casa, 2), - "meta_casa_pct": round(sum(m for m, _ in META_POR_CAUSA.values() - if m is not None), 2)} - -# -------------------------------------------------------------------------- -def con(): - c = sqlite3.connect(BANCO, timeout=10) - c.row_factory = sqlite3.Row - return c - - -def criar_tabelas() -> None: - with con() as c: - c.executescript(""" - 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, - reservado_por TEXT, reservado_em TEXT, - minutos_maquina INTEGER); -- estimativa; herda os marcadores 30min/1h/3h - CREATE TABLE IF NOT EXISTS movimento( - id INTEGER PRIMARY KEY AUTOINCREMENT, - arte_id INTEGER, pedido TEXT, de TEXT, para TEXT, - entrou_em TEXT, saiu_em TEXT, segundos INTEGER, - usuario TEXT, maquina TEXT); - CREATE TABLE IF NOT EXISTS job( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tipo TEXT, payload TEXT, tentativas INTEGER DEFAULT 0, - proxima_em REAL, erro TEXT, feito INTEGER DEFAULT 0); - CREATE INDEX IF NOT EXISTS ix_mov_arte ON movimento(arte_id); - CREATE INDEX IF NOT EXISTS ix_job_fila ON job(feito, proxima_em); - """) - - -criar_tabelas() - - -# -------------------------------------------------------------------------- -class Mover(BaseModel): - arte_id: int - para: str - usuario: str - maquina: str | None = None - - -@app.get("/api/quadro") -def quadro(): - agora = datetime.now() - with con() as c: - cards = c.execute("SELECT * FROM card ORDER BY desde").fetchall() - - saida = {i: [] for i in IDS} - for r in cards: - desde = datetime.fromisoformat(r["desde"]) - saida[r["coluna"]].append({ - "arte_id": r["arte_id"], "pedido": r["pedido"], "cliente": r["cliente"], - "metros": r["metros"], "maquina": r["maquina"], "partes": r["partes"], - "desde": r["desde"], - "parado_seg": int((agora - desde).total_seconds()), - "minutos_maquina": round(r["metros"] / 20 * 60), # 20 m/h por máquina - }) - return {"colunas": [{"id": c[0], "nome": c[1]} for c in COLUNAS], "cards": saida} - - -@app.post("/api/mover") -def mover(m: Mover): - if m.para not in IDS: - raise HTTPException(400, "coluna inválida") - - agora = datetime.now() - with con() as c: - card = c.execute("SELECT * FROM card WHERE arte_id=?", (m.arte_id,)).fetchone() - if not card: - raise HTTPException(404, "card não encontrado") - if card["coluna"] == m.para: - return {"ok": True, "sem_mudanca": True} - - de = card["coluna"] - desde = datetime.fromisoformat(card["desde"]) - - # 1. o movimento é gravado ANTES de qualquer integração externa - c.execute("""INSERT INTO movimento - (arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina) - VALUES (?,?,?,?,?,?,?,?,?)""", - (m.arte_id, card["pedido"], de, m.para, card["desde"], - agora.isoformat(timespec="seconds"), - int((agora - desde).total_seconds()), m.usuario, - m.maquina or card["maquina"])) - - c.execute("UPDATE card SET coluna=?, desde=?, maquina=COALESCE(?,maquina) " - "WHERE arte_id=?", - (m.para, agora.isoformat(timespec="seconds"), m.maquina, m.arte_id)) - - # 2. arquivo acompanha o card - mover_arquivos(card["pedido"], INFO[de][2], INFO[m.para][2]) - - # 3. jobs: marcador no Tiny e WhatsApp - # a máquina fica só no kanban — no Tiny basta saber que entrou em produção - marcador = INFO[m.para][3] - if marcador: - enfileirar(c, "tiny_marcador", - {"pedido": card["pedido"], "marcador": marcador}) - if m.para in AVISA_CLIENTE: - enfileirar(c, "whats", - {"pedido": card["pedido"], "tipo": AVISA_CLIENTE[m.para]}) - - return {"ok": True, "de": de, "para": m.para} - - -def mover_arquivos(pedido: str, pasta_de: str, pasta_para: str) -> None: - origem, destino = RAIZ / pasta_de, RAIZ / pasta_para - destino.mkdir(parents=True, exist_ok=True) - for f in origem.glob(f"{pedido}_*"): - shutil.move(str(f), str(destino / f.name)) - - -# -------------------------------------------------------------------------- -# Fila de jobs — tabela simples, sem Redis nem Celery -# -------------------------------------------------------------------------- -def enfileirar(c, tipo: str, payload: dict) -> None: - c.execute("INSERT INTO job(tipo,payload,proxima_em) VALUES (?,?,?)", - (tipo, json.dumps(payload), time.time())) - - -ESPERA = [60, 300, 1800] # 1 min, 5 min, 30 min - - -def worker() -> None: - while True: - try: - with con() as c: - job = c.execute( - "SELECT * FROM job WHERE feito=0 AND proxima_em<=? " - "ORDER BY id LIMIT 1", (time.time(),)).fetchone() - if not job: - time.sleep(3) - continue - - p = json.loads(job["payload"]) - try: - if job["tipo"] == "tiny_marcador": - tiny.marcador(p["pedido"], p["marcador"]) - elif job["tipo"] == "whats": - whats.avisar(p["pedido"], p["tipo"]) - c.execute("UPDATE job SET feito=1 WHERE id=?", (job["id"],)) - except Exception as e: - n = job["tentativas"] + 1 - if n > len(ESPERA): - c.execute("UPDATE job SET feito=1, erro=? WHERE id=?", - (f"desistiu: {e}", job["id"])) - # TODO: alertar o TI - else: - c.execute( - "UPDATE job SET tentativas=?, proxima_em=?, erro=? WHERE id=?", - (n, time.time() + ESPERA[n - 1], str(e), job["id"])) - except Exception: - time.sleep(5) - - -threading.Thread(target=worker, daemon=True).start() - - -# -------------------------------------------------------------------------- -# QR do carimbo aponta para cá -# -------------------------------------------------------------------------- -@app.get("/p/{pedido}") -def abrir_card(pedido: str): - """A revisão bipa o QR do filme e cai direto no card.""" - with con() as c: - card = c.execute("SELECT arte_id FROM card WHERE pedido=?", (pedido,)).fetchone() - if not card: - raise HTTPException(404, "pedido não encontrado no kanban") - return RedirectResponse(f"/?card={card['arte_id']}") - - -@app.get("/api/pedido/{pedido}") -def trilha(pedido: str): - """Tempo em cada etapa e quanto do total foi só esperando.""" - PARADO = {"rec", "tra", "fil", "cor"} - with con() as c: - movs = c.execute( - "SELECT * FROM movimento WHERE pedido=? ORDER BY id", (pedido,)).fetchall() - card = c.execute("SELECT * FROM card WHERE pedido=?", (pedido,)).fetchone() - if not card: - raise HTTPException(404) - - agora = int((datetime.now() - datetime.fromisoformat(card["desde"])).total_seconds()) - etapas = [{"coluna": m["de"], "nome": INFO[m["de"]][1], "segundos": m["segundos"]} - for m in movs] - etapas.append({"coluna": card["coluna"], "nome": INFO[card["coluna"]][1], - "segundos": agora, "atual": True}) - - total = sum(e["segundos"] for e in etapas) - esperando = sum(e["segundos"] for e in etapas if e["coluna"] in PARADO) - return {"pedido": pedido, "cliente": card["cliente"], "metros": card["metros"], - "etapas": etapas, "total_seg": total, "esperando_seg": esperando} - - -PERIODOS = {"hoje": 1, "7": 7, "30": 30, "mes": 30, "ant": 60} - - -@app.get("/api/relatorio/impressao") -def tempo_impressao(dias: int = 7): - """ - Tempo médio de impressão e velocidade real por máquina. - Sai da coluna 'imp' da tabela movimento, cruzada com os metros do card. - É este número que diz se 20 m/h é a velocidade real ou só a de catálogo. - """ - with con() as c: - linhas = c.execute(""" - SELECT m.maquina, COUNT(*) n, - AVG(m.segundos) media_seg, - SUM(c.metros) metros, - SUM(m.segundos) total_seg - FROM movimento m JOIN card c ON c.arte_id = m.arte_id - WHERE m.de = 'imp' AND m.saiu_em >= date('now', ?) - GROUP BY m.maquina""", (f"-{dias} days",)).fetchall() - saida = [] - for r in linhas: - horas = (r["total_seg"] or 0) / 3600 - saida.append({ - "maquina": r["maquina"], "pedidos": r["n"], - "media_min": round((r["media_seg"] or 0) / 60), - "metros": round(r["metros"] or 0, 1), - "m_por_hora": round((r["metros"] or 0) / horas, 1) if horas else None, - }) - return saida - - -# Depósito do Tiny para onde o insumo é transferido antes de imprimir. -# A Altus também VENDE insumo, então esse depósito separa o que é consumo -# interno do que é revenda. Confirmar o nome exato no cadastro do Tiny. -DEPOSITO_IMPRESSAO = "Sala DTF" -SKU_FILME = "DTF.FILME.57" - - -@app.get("/api/relatorio/aproveitamento") -def aproveitamento(dias: int = 30): - """ - Quanto do filme transferido virou metro faturado. - - Compra-se rolo de 100 m e imprime-se cerca de 90, por acerto de máquina, - prova de cor, refile e sobra de ponta. Cada ponto percentual vale cerca de - R$ 980/mês no volume atual. - - NÃO exige apontamento novo na sala: o consumo já é registrado hoje, quando - o insumo é transferido para o depósito de impressão no Tiny. Este endpoint - só lê a movimentação desse depósito. - """ - fat = 0.0 - with con() as c: - fat = c.execute( - "SELECT SUM(metros) m FROM card WHERE coluna='fin' AND desde >= date('now', ?)", - (f"-{dias} days",)).fetchone()["m"] or 0 - - cons = tiny.transferido_para_deposito( - sku=SKU_FILME, deposito=DEPOSITO_IMPRESSAO, dias=dias) - - pct = fat / cons * 100 if cons else None - return {"metros_faturados": round(fat, 1), - "metros_de_filme": round(cons, 1), - "fonte": f"transferências para o depósito {DEPOSITO_IMPRESSAO} no Tiny", - "aproveitamento_pct": round(pct, 1) if pct else None, - "valor_do_ponto_mes": 980} - - -@app.get("/api/relatorio/etapas") -def relatorio(dias: int = 7): - """Tempo médio por etapa. É o número que hoje não existe em lugar nenhum.""" - with con() as c: - linhas = c.execute(""" - SELECT de, COUNT(*) n, AVG(segundos) media, MAX(segundos) pior - FROM movimento - WHERE saiu_em >= date('now', ?) - GROUP BY de""", (f"-{dias} days",)).fetchall() - return [{"coluna": r["de"], "nome": INFO[r["de"]][1], "movimentos": r["n"], - "media_min": round(r["media"] / 60), "pior_min": round(r["pior"] / 60)} - for r in linhas] - - -@app.get("/") -def index(): - return FileResponse("static/kanban.html") diff --git a/kanban/static/kanban.html b/kanban/static/kanban.html deleted file mode 100644 index 4b1a249..0000000 --- a/kanban/static/kanban.html +++ /dev/null @@ -1,271 +0,0 @@ - - - - - -DTF · sala de impressão - - - - - - - -
-

Kanban da sala de DTF

- carregando… - — -
- - - -
-
-
- -
- - -
- - - - diff --git a/kanban/tiny.py b/kanban/tiny.py deleted file mode 100644 index 8e737d2..0000000 --- a/kanban/tiny.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -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() diff --git a/kanban/whats.py b/kanban/whats.py deleted file mode 100644 index 6b4e630..0000000 --- a/kanban/whats.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -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)) diff --git a/portal/main.py b/portal/main.py deleted file mode 100644 index 8687dad..0000000 --- a/portal/main.py +++ /dev/null @@ -1,322 +0,0 @@ -""" -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()} diff --git a/portal/preflight.py b/portal/preflight.py deleted file mode 100644 index c2cd327..0000000 --- a/portal/preflight.py +++ /dev/null @@ -1,415 +0,0 @@ -""" -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"{pedido}\n" - f"{metros:.2f} m\n" - f"" - f"{quando:%d/%m · %H:%M}" - ).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)") diff --git a/portal/tiny.py b/portal/tiny.py deleted file mode 100644 index 8e737d2..0000000 --- a/portal/tiny.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -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() diff --git a/portal/whats.py b/portal/whats.py deleted file mode 100644 index 6b4e630..0000000 --- a/portal/whats.py +++ /dev/null @@ -1,68 +0,0 @@ -""" -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)) diff --git a/schema.sql b/schema.sql deleted file mode 100644 index ccb5b4c..0000000 --- a/schema.sql +++ /dev/null @@ -1,222 +0,0 @@ --- ===================================================================== --- Módulo DTF — schema completo --- ===================================================================== --- Duas bases: --- NUVEM (PostgreSQL, no VPS) → pedido, arte, parte, job, agente --- LOCAL (SQLite, na fábrica) → card, movimento, retrabalho, job_local --- --- A separação é proposital: o kanban precisa funcionar com a internet fora. --- ===================================================================== - - --- ===================================================================== --- NUVEM · PostgreSQL --- ===================================================================== - -CREATE TABLE pedido ( - id BIGSERIAL PRIMARY KEY, - numero_tiny TEXT NOT NULL UNIQUE, -- chave de tudo - cliente TEXT NOT NULL, - telefone TEXT NOT NULL, - metros NUMERIC(10,2) NOT NULL, -- metros comprados por arte - cliente_novo BOOLEAN NOT NULL DEFAULT false,-- dispara prova de cor - token TEXT NOT NULL UNIQUE, -- UUID v4 do link - token_expira TIMESTAMPTZ NOT NULL, -- 7 dias - lembrete_em TIMESTAMPTZ, -- 1h sem arte → cobra - criado_em TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX ix_pedido_token ON pedido(token); -CREATE INDEX ix_pedido_semarte ON pedido(criado_em) - WHERE NOT EXISTS (SELECT 1 FROM arte WHERE arte.pedido_id = pedido.id); - - -CREATE TABLE arte ( - id BIGSERIAL PRIMARY KEY, - pedido_id BIGINT NOT NULL REFERENCES pedido(id), - arquivo TEXT NOT NULL, -- chave no storage - arquivo_orig TEXT, -- original preservado - bytes BIGINT NOT NULL DEFAULT 0, - formato_orig TEXT, -- png, pdf, psd, cdr... - natureza TEXT, -- vetor | raster | conferir - repeticoes INT NOT NULL DEFAULT 1, - status TEXT NOT NULL DEFAULT 'recebida', - -- recebida | processando | aprovada | recusada - conferir_manual BOOLEAN NOT NULL DEFAULT false,-- CDR - motivo TEXT, -- por que foi recusada - avisos TEXT, -- aceitou mas com ressalva - dpi_efetivo NUMERIC(8,2), - largura_cm NUMERIC(8,2), - metros_totais NUMERIC(10,2), - minutos_maquina INT, -- estimativa; vira campo no card - cores_tensas TEXT, -- fora do gamut, se houver - virus_ok BOOLEAN NOT NULL DEFAULT false,-- ClamAV liberou - baixada BOOLEAN NOT NULL DEFAULT false,-- o agente já trouxe - criado_em TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX ix_arte_pedido ON arte(pedido_id); -CREATE INDEX ix_arte_baixar ON arte(status, baixada, virus_ok); - - -CREATE TABLE parte ( - id BIGSERIAL PRIMARY KEY, - arte_id BIGINT NOT NULL REFERENCES arte(id), - ordem INT NOT NULL, - metros NUMERIC(10,2) NOT NULL, - arquivo TEXT NOT NULL, - carimbada BOOLEAN NOT NULL DEFAULT false,-- só a última leva carimbo - UNIQUE (arte_id, ordem) -); - - --- Fila de jobs. Tabela, não broker: no volume da Altus (algumas centenas de --- pedidos/dia) Redis e Celery são complexidade sem retorno. --- Consumir com: SELECT ... FOR UPDATE SKIP LOCKED -CREATE TABLE job ( - id BIGSERIAL PRIMARY KEY, - tipo TEXT NOT NULL, -- preflight | whats | tiny | limpeza - payload JSONB NOT NULL, - tentativas INT NOT NULL DEFAULT 0, - proxima_em TIMESTAMPTZ NOT NULL DEFAULT now(), - erro TEXT, - feito BOOLEAN NOT NULL DEFAULT false, - criado_em TIMESTAMPTZ NOT NULL DEFAULT now() -); -CREATE INDEX ix_job_fila ON job(feito, proxima_em) WHERE NOT feito; - - -CREATE TABLE agente_heartbeat ( - id SERIAL PRIMARY KEY, - visto_em TIMESTAMPTZ NOT NULL DEFAULT now() -); --- sem sinal por 15 min → alertar o TI - - --- Artes guardadas para recompra. Prefixo separado no storage, retenção 12 meses. -CREATE TABLE arte_cliente ( - id BIGSERIAL PRIMARY KEY, - numero_cliente TEXT NOT NULL, - token_cliente TEXT NOT NULL, -- link permanente - arte_id BIGINT REFERENCES arte(id), - arquivo TEXT NOT NULL, -- só a arte tratada - descricao TEXT, - criado_em TIMESTAMPTZ NOT NULL DEFAULT now(), - expira_em TIMESTAMPTZ NOT NULL -); -CREATE INDEX ix_arte_cliente ON arte_cliente(numero_cliente, token_cliente); - - --- ===================================================================== --- LOCAL · SQLite (na fábrica) --- ===================================================================== - -CREATE TABLE card ( - arte_id INTEGER PRIMARY KEY, - pedido TEXT NOT NULL, - cliente TEXT NOT NULL, - metros REAL NOT NULL, - minutos_maquina INTEGER, -- estimativa. Herda os marcadores 30min/1h/3h - -- do Tiny. Sugerida por metros/20*60; quem - -- trata a arte ajusta só quando foge do normal - partes INTEGER NOT NULL DEFAULT 1, - coluna TEXT NOT NULL DEFAULT 'rec', - -- rec | tra | apc | fil | imp | cor | fin - -- NÃO existe coluna de aplicação: a sala só imprime o filme - desde TEXT NOT NULL, -- entrada na coluna atual (ISO) - maquina TEXT, -- 'Maq 1' … 'Maq 6' - reservado_por TEXT, -- máquina que puxou - reservado_em TEXT, -- expira em 3 min (ajuste da sala) - conferir_manual INTEGER NOT NULL DEFAULT 0 -); -CREATE INDEX ix_card_coluna ON card(coluna, desde); - - --- A tabela mais importante do sistema. Dela saem TODOS os números que hoje --- não existem: tempo por etapa, fila por máquina, retrabalho, ocupação real. -CREATE TABLE movimento ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - arte_id INTEGER NOT NULL, - pedido TEXT NOT NULL, - de TEXT NOT NULL, - para TEXT NOT NULL, - entrou_em TEXT NOT NULL, - saiu_em TEXT NOT NULL, - segundos INTEGER NOT NULL, - usuario TEXT NOT NULL, -- login do PCP. Se o PC tiver login - -- compartilhado, o indicador de retrabalho - -- por pessoa fica sem dono - maquina TEXT -); -CREATE INDEX ix_mov_arte ON movimento(arte_id); -CREATE INDEX ix_mov_saiu ON movimento(saiu_em); -CREATE INDEX ix_mov_etapa ON movimento(de, saiu_em); - - -CREATE TABLE retrabalho ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - pedido_origem TEXT NOT NULL, - pedido_novo TEXT, - metros REAL NOT NULL, - causa TEXT NOT NULL, - -- arte_cliente | tratamento | impressao | perfil_cor | pedido - -- TRAVADA depois de aberta: com o indicador atrelado a bônus, - -- haveria incentivo para reclassificar falha de máquina como - -- culpa do cliente - aberto_por TEXT NOT NULL, -- Mayana: conhece o cliente e a reclamação - aberto_em TEXT NOT NULL, - vez INTEGER NOT NULL DEFAULT 1, -- 2ª sobe alçada, 3ª trava - alcada TEXT NOT NULL, -- sala | financeiro | diretoria - autorizado_por TEXT, -- Thales ou Alexandre: entra na meta deles - autorizado_em TEXT, - contestado_por TEXT, -- discorda da causa mas não pode mudá-la - contestado_em TEXT, - contestacao TEXT, - evidencia TEXT -- foto ou print da reclamação -); -CREATE INDEX ix_retra_data ON retrabalho(aberto_em); -CREATE INDEX ix_retra_causa ON retrabalho(causa, aberto_em); - - -CREATE TABLE job_local ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - tipo TEXT NOT NULL, -- tiny_marcador | whats - payload TEXT NOT NULL, - tentativas INTEGER NOT NULL DEFAULT 0, - proxima_em REAL NOT NULL, -- 1, 5 e 30 min - erro TEXT, - feito INTEGER NOT NULL DEFAULT 0 -); -CREATE INDEX ix_joblocal ON job_local(feito, proxima_em); - - --- Consumo de filme, para o aproveitamento. NÃO exige apontamento novo: --- lê as transferências para o depósito de impressão no Tiny. -CREATE TABLE consumo_filme ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - em TEXT NOT NULL, - metros REAL NOT NULL, - origem TEXT NOT NULL DEFAULT 'tiny' -- transferência de depósito -); - - --- ===================================================================== --- Consultas que o painel usa --- ===================================================================== - --- Tempo médio por etapa nos últimos N dias --- SELECT de, COUNT(*) n, AVG(segundos)/60 media_min, MAX(segundos)/60 pior_min --- FROM movimento WHERE saiu_em >= date('now','-7 days') GROUP BY de; - --- Velocidade real por máquina — é o número que valida ou derruba os 20 m/h --- SELECT m.maquina, SUM(c.metros) metros, SUM(m.segundos)/3600.0 horas, --- SUM(c.metros)/(SUM(m.segundos)/3600.0) m_por_hora --- FROM movimento m JOIN card c ON c.arte_id = m.arte_id --- WHERE m.de='imp' AND m.saiu_em >= date('now','-7 days') GROUP BY m.maquina; - --- Retrabalho por causa, com responsável --- SELECT causa, COUNT(*) n, SUM(metros) m FROM retrabalho --- WHERE aberto_em >= date('now','-30 days') GROUP BY causa; - --- Aproveitamento do filme --- faturado = SELECT SUM(metros) FROM card WHERE coluna='fin' AND desde >= ... --- consumido = SELECT SUM(metros) FROM consumo_filme WHERE em >= ... --- aproveitamento = faturado / consumido