chore: remove the abandoned prototypes and archive what described them

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 <noreply@anthropic.com>
This commit is contained in:
Cauê Faleiros
2026-09-21 16:34:20 -03:00
parent 86b8199612
commit ca698434a2
21 changed files with 47 additions and 2292 deletions

View File

@@ -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://<conta>.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

View File

@@ -4,7 +4,7 @@
Read this document before changing code, documentation, architecture, or scope. Read this document before changing code, documentation, architecture, or scope.
It is the current English source of truth for the project. It supersedes older 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. prototype code whenever they conflict.
Update this file whenever the team makes a material product, process, or 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 activation remain incomplete. Deployment plumbing exists but cannot pass its
release gate yet. No factory automation or pre-flight is added. 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 decisions and evidence required before staging or production connection, and
`PORTAINER.md` for the production delivery contract. `PORTAINER.md` for the production delivery contract.
The client roadmap promises are unchanged, so its PDF source is not regenerated The client roadmap promises are unchanged, so its PDF source is not regenerated
@@ -348,13 +348,15 @@ for this local implementation checkpoint.
### Existing assets ### Existing assets
- `dtf-site.html`: rich static front-end prototype. It includes the current - `dtf-site.html`: the Site. Commercial rules, artwork analysis, cart and
commercial rules, client-side artwork analysis, current-session cart, and delivery UI; its behaviour lives in `local/static/site-*.js`.
delivery UI. The localhost checkout bridge connects it to the new local API. - `docs/historico/`: the original specification, endpoint sketch, task plan and
- `portal/`: early FastAPI prototype for the original Tiny-first, token-link status report. Background only — they describe a model this system does not
upload flow. implement.
- `kanban/`: early FastAPI/SQLite Kanban prototype.
- `agente/`: factory-side agent prototype; out of current MVP scope. 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 - `tmp/pdfs/generate_dtf_report.py`: generator for the current client-facing
roadmap PDF. roadmap PDF.
- `output/pdf/dtf-plano-producao-e-roadmap.pdf`: current generated roadmap. - `output/pdf/dtf-plano-producao-e-roadmap.pdf`: current generated roadmap.

View File

@@ -14,6 +14,16 @@
> a arquitetura, o código e o que ainda depende de resposta. > a arquitetura, o código e o que ainda depende de resposta.
> Base: conversas de 29 e 30/08/2026 com Marcus. > 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 ## Por que este projeto existe

View File

@@ -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()

View File

@@ -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

26
docs/historico/README.md Normal file
View File

@@ -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
```

View File

@@ -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")

View File

@@ -1,271 +0,0 @@
<!DOCTYPE html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>DTF · sala de impressão</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
<style>
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#7C828C;
--ciano:#00B8DA;--magenta:#E0357C;--amarelo:#EFB700;--branco:#EDEBE6;--verde:#48B072;
--alerta:#E0642A;--roxo:#8E7CE8}
*{box-sizing:border-box;margin:0;padding:0}
body{background:var(--bg);color:var(--tx);font-family:"IBM Plex Mono",ui-monospace,monospace;
font-size:14px;line-height:1.45;-webkit-font-smoothing:antialiased;padding:16px 14px 34px}
h1,h2{font-family:"Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
.topo{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
h1{font-size:23px;font-weight:700}
.sub{color:var(--fraco);font-size:12px}
.rel{margin-left:auto;font-family:"Space Grotesk",sans-serif;font-size:19px;font-weight:700;color:var(--ciano)}
.aviso{background:#152026;border:1px solid #24404A;border-left:3px solid var(--ciano);
border-radius:0 8px 8px 0;padding:10px 14px;font-size:12.5px;color:#B9CBD2;margin:10px 0 14px}
.aviso b{color:var(--ciano);font-family:"Space Grotesk",sans-serif}
/* máquinas */
.maqs{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:14px}
@media(max-width:900px){.maqs{grid-template-columns:repeat(3,1fr)}}
.mq{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 12px;position:relative}
.mq.ocup{border-color:var(--alerta)}
.mq .luz{width:11px;height:11px;border-radius:50%;position:absolute;top:11px;right:11px;background:var(--verde)}
.mq.ocup .luz{background:var(--alerta);box-shadow:0 0 0 3px rgba(224,100,42,.2)}
.mq b{font-family:"Space Grotesk",sans-serif;font-size:13px;display:block;margin-bottom:4px}
.mq .st{font-size:10.5px;color:var(--fraco);line-height:1.4;min-height:28px}
.mq button{margin-top:8px;width:100%;font-family:inherit;font-size:11px;padding:6px;border-radius:6px;
border:1px solid var(--linha);background:var(--card2);color:var(--fraco);cursor:pointer}
.mq.livre button{background:var(--ciano);border-color:var(--ciano);color:#03181D;font-weight:600}
.mq.livre button:hover{filter:brightness(1.1)}
.mq.ocup button{opacity:.4;cursor:default}
/* kpis */
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:9px;margin-bottom:14px}
.k{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 14px;border-left:3px solid var(--c,var(--linha))}
.k dt{font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--fraco);margin-bottom:3px}
.k dd{font-family:"Space Grotesk",sans-serif;font-size:23px;font-weight:700}
.k dd small{font-size:12px;color:var(--fraco);font-weight:400;font-family:"IBM Plex Mono",monospace}
/* kanban */
.kan{display:grid;grid-template-columns:repeat(7,minmax(140px,1fr));gap:8px;overflow-x:auto;padding-bottom:16px}
@media(max-width:1200px){.kan{grid-template-columns:repeat(7,168px)}}
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;display:flex;flex-direction:column;min-height:250px}
.col.alvo{border-color:var(--ciano);background:#101820}
.ch{padding:10px 11px 8px;border-bottom:1px solid var(--linha)}
.ch b{font-family:"Space Grotesk",sans-serif;font-size:12px;display:block}
.ch b .p{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--cc)}
.ch small{font-size:10px;color:var(--fraco)}
.cb{padding:8px;display:flex;flex-direction:column;gap:6px;flex:1}
.vazio{color:var(--fraco);font-size:10.5px;text-align:center;padding:14px 4px}
.cd{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc);
border-radius:5px;padding:8px 9px;cursor:grab;font-size:11px}
.cd:active{cursor:grabbing}
.cd.arrasta{opacity:.35}
.cd b{display:block;font-family:"Space Grotesk",sans-serif;font-size:12.5px}
.cd .cli{color:var(--fraco);font-size:10px;margin:1px 0 5px}
.cd .l{display:flex;justify-content:space-between;font-size:10px}
.cd .l i{font-style:normal;color:var(--fraco)}
.cd .mq2{display:inline-block;font-size:9px;padding:1px 5px;border-radius:3px;background:var(--linha);margin-top:4px}
.cd.q .l i{color:var(--amarelo)}
.cd.r{border-left-color:var(--alerta)}
.cd.r .l i{color:var(--alerta)}
.rod{margin-top:14px;display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--fraco);font-size:11.5px}
.rod button{font-family:inherit;font-size:11.5px;padding:7px 12px;border-radius:7px;
border:1px solid var(--linha);background:none;color:var(--fraco);cursor:pointer}
.rod button:hover{border-color:var(--ciano);color:var(--ciano)}
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
</style>
</head>
<body>
<div class="topo">
<h1>Kanban da sala de DTF</h1>
<span class="sub" id="ctx">carregando…</span>
<span class="rel" id="hora">—</span>
</div>
<div class="aviso" id="aviso" style="display:none"></div>
<div class="maqs" id="maqs"></div>
<dl class="kpis" id="kpis"></dl>
<div class="kan" id="kan"></div>
<div class="rod">
<button id="atualizar">Atualizar</button>
<span id="msg"></span>
</div>
<script>
const COLS=[
{id:'rec', nome:'Arte recebida', cor:'var(--ciano)'},
{id:'tra', nome:'Arte tratada', cor:'var(--magenta)'},
{id:'cor2',nome:'Prova de cor', cor:'var(--roxo)'},
{id:'fil', nome:'Fila', cor:'var(--amarelo)'},
{id:'imp', nome:'Imprimindo', cor:'var(--branco)'},
{id:'cor', nome:'Correção', cor:'var(--alerta)'},
{id:'fin', nome:'Finalizado', cor:'var(--verde)'}
];
const API=''; // mesma origem do PCP
const USUARIO=window.PCP_USUARIO || 'operador';
const MAQUINA=new URLSearchParams(location.search).get('maq'); // ?maq=4
const PARADO=['rec','tra','cor2','fil','cor'];
const H=36e5, M=6e4;
let cards=[], maquinas=[], erro=false;
async function api(rota,opt){
const r=await fetch(API+rota,{headers:{'Content-Type':'application/json'},...opt});
if(!r.ok){
let m='erro '+r.status;
try{ m=(await r.json()).detail || m; }catch(e){}
throw new Error(m);
}
return r.status===204? null : r.json();
}
async function carregar(){
try{
const d=await api('/api/quadro');
cards=[]; maquinas=d.maquinas||[];
Object.entries(d.cards||{}).forEach(([col,lista])=>
lista.forEach(c=>cards.push({
id:String(c.arte_id), pedido:c.pedido, cli:c.cliente,
m:c.metros, min:c.minutos_maquina || Math.round(c.metros/20*60),
col, desde:Date.parse(c.desde), maq:c.maquina? +String(c.maquina).replace(/\D/g,''):null
})));
erro=false; esconderAviso();
}catch(e){
erro=true; mostrarAviso('Sem conexão com o servidor — '+e.message+'. A tela não está atualizando.');
}
pinta();
}
function mostrarAviso(t){
const a=document.getElementById('aviso');
a.style.display='block'; a.innerHTML='<b>Atenção</b> '+t;
}
function esconderAviso(){ document.getElementById('aviso').style.display='none'; }
function salvar(){ /* estado vive no servidor */ }
async function puxar(n){
try{
const r=await api('/api/puxar',{method:'POST',
body:JSON.stringify({maquina:n, usuario:USUARIO})});
msg('Maq '+n+' puxou o #'+r.pedido+' · '+r.minutos+' min · reserva de '+r.reserva_expira_em+' min');
}catch(e){ msg(e.message); }
carregar();
}
async function mover(arte_id,para,maq){
try{
await api('/api/mover',{method:'POST',
body:JSON.stringify({arte_id:+arte_id, para, usuario:USUARIO, maquina:maq||null})});
}catch(e){ msg(e.message); }
carregar();
}
async function devolver(arte_id){
try{
await api('/api/devolver',{method:'POST',
body:JSON.stringify({arte_id:+arte_id, usuario:USUARIO})});
msg('devolvido ao topo da fila');
}catch(e){ msg(e.message); }
carregar();
}
function pintaMaqs(){
const el=document.getElementById('maqs'); el.innerHTML='';
for(let n=1;n<=6;n++){
const info=maquinas.find(x=>x.n===n) || {};
const job=cards.find(c=>c.col==='imp' && c.maq===n) ||
(info.ocupada? {pedido:info.pedido,m:info.metros,desde:Date.now()-(info.rodando_seg||0)*1000}:null);
if(MAQUINA && String(n)!==MAQUINA) continue;
const d=document.createElement('div');
d.className='mq '+(job?'ocup':'livre');
d.innerHTML='<span class="luz"></span><b>Maq '+n+'</b>'+
'<div class="st">'+(job
? '#'+job.pedido+' · '+job.m.toFixed(1).replace('.',',')+' m<br>rodando há '+dur(Date.now()-job.desde)
: 'livre<br>&nbsp;')+'</div>'+
'<button '+(job?'disabled':'')+'>'+(job?'ocupada':'puxar próximo')+'</button>';
if(!job) d.querySelector('button').addEventListener('click',()=>puxar(n));
el.appendChild(d);
}
}
function puxar(n){
const fila=cards.filter(c=>c.col==='fil').sort((a,b)=>a.desde-b.desde);
if(!fila.length) return msg('a fila está vazia');
const c=fila[0];
c.col='imp'; c.maq=n; c.desde=Date.now();
salvar(); pinta();
msg('Maq '+n+' puxou o #'+c.pedido+' — o mais antigo da fila');
}
function pintaKpis(){
const fila=cards.filter(c=>['rec','tra','cor2','fil'].includes(c.col));
const mf=fila.reduce((s,c)=>s+c.m,0), mn=fila.reduce((s,c)=>s+c.min,0);
const feito=cards.filter(c=>c.col==='fin').reduce((s,c)=>s+c.m,0);
const livres=[1,2,3,4,5,6].filter(n=>!cards.some(c=>c.col==='imp'&&c.maq===n)).length;
const cap=20*6*4.5; // 6 máquinas, 20 m/h, 4h30 restantes
const risco=cards.filter(c=>PARADO.includes(c.col)&&Date.now()-c.desde>3*H).length;
const k=[
['Na fila', mf.toFixed(1).replace('.',',')+' <small>m</small>','var(--amarelo)'],
['Minutos de máquina', mn+' <small>min</small>','var(--amarelo)'],
['Capacidade até 20h', Math.round(cap)+' <small>m</small>', mf>cap?'var(--alerta)':'var(--verde)'],
['Máquinas livres',livres+' <small>de 6</small>','var(--ciano)'],
['Impresso hoje', feito.toFixed(1).replace('.',',')+' <small>m</small>','var(--verde)'],
['Parados +3h', risco+' <small>pedido'+(risco===1?'':'s')+'</small>', risco?'var(--alerta)':'var(--verde)']
];
document.getElementById('kpis').innerHTML=k.map(x=>
'<div class="k" style="--c:'+x[2]+'"><dt>'+x[0]+'</dt><dd>'+x[1]+'</dd></div>').join('');
}
function pinta(){
pintaMaqs(); pintaKpis();
const agora=Date.now(), el=document.getElementById('kan'); el.innerHTML='';
COLS.forEach(col=>{
const its=cards.filter(c=>c.col===col.id).sort((a,b)=>a.desde-b.desde);
const mt=its.reduce((s,c)=>s+c.m,0);
const d=document.createElement('div');
d.className='col'; d.dataset.col=col.id; d.style.setProperty('--cc',col.cor);
d.innerHTML='<div class="ch"><b><span class="p"></span>'+col.nome+'</b>'+
'<small>'+its.length+' · '+mt.toFixed(1).replace('.',',')+' m</small></div><div class="cb"></div>';
const cb=d.querySelector('.cb');
if(!its.length) cb.innerHTML='<div class="vazio">—</div>';
its.forEach(c=>{
const p=agora-c.desde, fin=col.id==='fin';
const cl=fin?'':(p>3*H?' r':(p>1*H?' q':''));
const e=document.createElement('div');
e.className='cd'+cl; e.draggable=true; e.dataset.id=c.id;
e.innerHTML='<b>#'+c.pedido+'</b><div class="cli">'+c.cli+'</div>'+
'<div class="l"><i>'+(fin?'concluído':'há '+dur(p))+'</i>'+
'<span>'+c.m.toFixed(1).replace('.',',')+' m · '+c.min+' min</span></div>'+
(c.maq?'<span class="mq2">Maq '+c.maq+'</span>':'');
e.addEventListener('dragstart',ev=>{ev.dataTransfer.setData('text/plain',c.id);e.classList.add('arrasta')});
e.addEventListener('dragend',()=>e.classList.remove('arrasta'));
cb.appendChild(e);
});
d.addEventListener('dragover',ev=>{ev.preventDefault();d.classList.add('alvo')});
d.addEventListener('dragleave',()=>d.classList.remove('alvo'));
d.addEventListener('drop',ev=>{
ev.preventDefault(); d.classList.remove('alvo');
const c=cards.find(x=>x.id===ev.dataTransfer.getData('text/plain'));
if(!c||c.col===col.id) return;
if(c.col==='imp'&&col.id==='fil') return devolver(c.id);
let maq=null;
if(col.id==='imp'){
maq=[1,2,3,4,5,6].find(n=>!cards.some(x=>x.col==='imp'&&x.maq===n));
if(!maq) return msg('todas as máquinas estão ocupadas');
}
mover(c.id,col.id,maq);
});
el.appendChild(d);
});
}
document.getElementById('atualizar').addEventListener('click',carregar);
document.getElementById('ctx').textContent =
MAQUINA ? 'Maq '+MAQUINA+' · '+USUARIO : 'visão geral · '+USUARIO;
function tic(){
document.getElementById('hora').textContent=
new Date().toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'});
}
tic(); setInterval(tic,30000);
setInterval(pinta,30000); // relógio dos cards
setInterval(carregar,15000); // busca o estado no servidor
carregar();
</script>
</body>
</html>

View File

@@ -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()

View File

@@ -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))

View File

@@ -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()}

View File

@@ -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"<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)")

View File

@@ -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()

View File

@@ -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))

View File

@@ -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