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:
27
.env.exemplo
27
.env.exemplo
@@ -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
|
|
||||||
20
CONTEXT.md
20
CONTEXT.md
@@ -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.
|
||||||
|
|||||||
10
README.md
10
README.md
@@ -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
|
||||||
|
|||||||
141
agente/agente.py
141
agente/agente.py
@@ -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()
|
|
||||||
@@ -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
26
docs/historico/README.md
Normal 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
|
||||||
|
```
|
||||||
557
kanban/main.py
557
kanban/main.py
@@ -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")
|
|
||||||
@@ -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> ')+'</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>
|
|
||||||
@@ -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()
|
|
||||||
@@ -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))
|
|
||||||
322
portal/main.py
322
portal/main.py
@@ -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()}
|
|
||||||
@@ -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)")
|
|
||||||
@@ -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()
|
|
||||||
@@ -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))
|
|
||||||
222
schema.sql
222
schema.sql
@@ -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
|
|
||||||
Reference in New Issue
Block a user