Compare commits

..

3 Commits

Author SHA1 Message Date
Cauê Faleiros
66ddb17f02 chore: untrack deliverables committed by mistake, and home the stray files
All checks were successful
Build and deploy / Validate source (push) Successful in 10s
Build and deploy / Integration suite on a real stack (push) Successful in 1m47s
Build and deploy / Secret scan and release gate (push) Successful in 7s
Build and deploy / Publish images and notify Portainer (push) Successful in 1m38s
The previous commit used git add -A and swept in four binaries that were
deliberately untracked: the week-1 client report as .docx and .pdf, a duplicate
of it under output/documents, and imagem-teste.jpg, an input dropped in to test
with. None of them are the repository's to version. They are untracked here,
left on disk, and covered by .gitignore so the mistake cannot repeat.

Three files had no sensible home. The meeting notes sat at the repository root
under a 78-character name with spaces and accents, the roadmap generator lived
in tmp/ — a directory otherwise ignored as scratch — and its output in output/,
which is otherwise generated evidence. They are now docs/reuniao-2026-09-09-
anotacoes.pdf, tools/generate_dtf_report.py and docs/roadmap-cliente.pdf, with
CONTEXT.md and ROADMAP.md updated to match and a docs/README.md saying what each
document is for.

.gitignore no longer needs four rules to keep one generator out of an ignored
directory; tmp/ is scratch again.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:47:36 -03:00
Cauê Faleiros
96f1d27221 refactor: split the Site's behaviour out of one 1,575-line inline script
dtf-site.html held commercial rules, the nesting engine, PDF analysis, the cart
and every handler in a single inline script, 42% of the runtime code in one
file, and the money logic lived in the middle of it.

It is now nine files under local/static, cut at the section markers the original
author left, so no function was split across a boundary: config, product modes,
upload, sheet analysis, PDF, quality, packing, cart, flow. They load as classic
scripts in the original order and share one global scope, so evaluation is
exactly what it was; the extraction was checked byte-identical against the
original before the tags replaced it. dtf-site.html is 1,394 lines of markup and
style.

With no inline script left anywhere, the policy no longer needs a hash
allowlist: script-src is now 'self' alone, which is stronger than what it
replaced and cannot drift as the page changes.

Three things depended on the old shape and were updated rather than worked
around. The pricing parity test read the ladder out of the HTML and now reads it
from site-config.js, still proving the server agrees with what the customer is
shown. The isolated artwork test served four hardcoded script paths and now
serves any script that resolves inside local/static, so the next file added does
not silently 404. The CSP assertion checked the whole policy for 'unsafe-inline'
and now checks the script-src directive alone, since style-src legitimately
carries it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-21 16:44:49 -03:00
Cauê Faleiros
ca698434a2 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>
2026-09-21 16:34:20 -03:00
41 changed files with 1754 additions and 3901 deletions

View File

@@ -1,27 +0,0 @@
# HISTORICAL PROTOTYPE ONLY. Do not use for the local stack; use .env.example.
# ---- portal (nuvem) ----
DATABASE_URL=postgresql+psycopg://dtf:senha@localhost/dtf
S3_ENDPOINT=https://<conta>.r2.cloudflarestorage.com
S3_BUCKET=dtf-artes
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
BASE_PORTAL=https://arte.dropstaratacado.com.br
BASE_KANBAN=http://servidor:8080
WEBHOOK_TOKEN=troque-isto
AGENTE_TOKEN=troque-isto-tambem
AGENTE_WEBHOOK=http://ip-da-fabrica:8080/api/agente/acordar
# ---- Tiny ----
TINY_CLIENT_ID=
TINY_CLIENT_SECRET=
TINY_TOKEN_URL=
TINY_BASE=https://api.tiny.com.br/public-api/v3
# ---- WhatsApp ----
WHATS_PROVEDOR=meta
WHATS_TOKEN=
WHATS_NUMERO_ID=
# ---- fábrica ----
PASTA_DTF=\\servidor\DTF
KANBAN_DB=C:\dtf\kanban.db

14
.gitignore vendored
View File

@@ -35,8 +35,12 @@ deploy/portainer.env
output/local/
output/security/
# Keep the roadmap generator, not its extracted text or render-review scratch.
tmp/*
!tmp/pdfs/
tmp/pdfs/*
!tmp/pdfs/generate_dtf_report.py
# Scratch only. The roadmap generator moved to tools/, the PDFs to docs/.
tmp/
# Client deliverables and scratch inputs live here but are not the repository's
# to version: they are produced for a specific week, or dropped in to test with.
Relatorio-*.docx
Relatorio-*.pdf
imagem-teste.*
output/documents/

View File

@@ -4,7 +4,7 @@
Read this document before changing code, documentation, architecture, or scope.
It is the current English source of truth for the project. It supersedes older
decisions in `README.md`, `ESPECIFICACAO.md`, `schema.sql`, and the existing
decisions in `README.md`, `docs/historico/ESPECIFICACAO.md`, `schema.sql`, and the existing
prototype code whenever they conflict.
Update this file whenever the team makes a material product, process, or
@@ -340,7 +340,7 @@ as references and are not imported or started by Compose.
activation remain incomplete. Deployment plumbing exists but cannot pass its
release gate yet. No factory automation or pre-flight is added.
See `IMPLEMENTATION_REPORT.md` for verification, `PRODUCTION_INPUTS.md` for the
See `docs/historico/IMPLEMENTATION_REPORT.md` for verification, `PRODUCTION_INPUTS.md` for the
decisions and evidence required before staging or production connection, and
`PORTAINER.md` for the production delivery contract.
The client roadmap promises are unchanged, so its PDF source is not regenerated
@@ -348,17 +348,18 @@ for this local implementation checkpoint.
### Existing assets
- `dtf-site.html`: rich static front-end prototype. It includes the current
commercial rules, client-side artwork analysis, current-session cart, and
delivery UI. The localhost checkout bridge connects it to the new local API.
- `portal/`: early FastAPI prototype for the original Tiny-first, token-link
upload flow.
- `kanban/`: early FastAPI/SQLite Kanban prototype.
- `agente/`: factory-side agent prototype; out of current MVP scope.
- `tmp/pdfs/generate_dtf_report.py`: generator for the current client-facing
roadmap PDF.
- `output/pdf/dtf-plano-producao-e-roadmap.pdf`: current generated roadmap.
- `Reunião iniciada às 2026_09_09 09_39 GMT-03_00 - Anotações do Gemini.pdf`:
- `dtf-site.html`: the Site. Commercial rules, artwork analysis, cart and
delivery UI; its behaviour lives in `local/static/site-*.js`.
- `docs/historico/`: the original specification, endpoint sketch, task plan and
status report. Background only — they describe a model this system does not
implement.
The prototypes they describe (`portal/`, `kanban/`, `agente/`, the root
`schema.sql` and `.env.exemplo`) were removed on 2026-09-21 once nothing
referenced them; recover from Git history if ever needed.
- `tools/generate_dtf_report.py`: generator for the client-facing roadmap PDF.
- `docs/roadmap-cliente.pdf`: the generated roadmap, as last sent.
- `docs/reuniao-2026-09-09-anotacoes.pdf`:
meeting notes that established the business direction. Treat it as context;
the latest decisions in this document define the active scope.
@@ -382,8 +383,7 @@ for this local implementation checkpoint.
`context.md` is the compact operating context for agents. The current
client-facing narrative, diagrams, and formatting live in
`output/pdf/dtf-plano-producao-e-roadmap.pdf`, generated from
`tmp/pdfs/generate_dtf_report.py`.
`docs/roadmap-cliente.pdf`, generated from `tools/generate_dtf_report.py`.
When a decision changes, update both the relevant implementation context here
and the roadmap source when it changes what the client-facing plan promises.

View File

@@ -14,6 +14,16 @@
> a arquitetura, o código e o que ainda depende de resposta.
> Base: conversas de 29 e 30/08/2026 com Marcus.
> **2026-09-21.** Everything below this line is the original prototype
> documentation. The code it describes (`portal/`, `kanban/`, `agente/`, the root
> `schema.sql`, `.env.exemplo`) no longer exists, and the model it describes is
> not the one implemented. Kept for the business reasoning in it — the capacity
> figures, the cost argument, the meeting decisions. For how the system actually
> works read [CONTEXT.md](CONTEXT.md); for what is outstanding read
> [ROADMAP.md](ROADMAP.md); for the original documents see
> [docs/historico/](docs/historico/README.md).
---
## Por que este projeto existe

View File

@@ -1,8 +1,8 @@
# DTF System — Working Roadmap
> Internal engineering tracker. Not a client document, not a promise sheet.
> The client-facing narrative lives in `output/pdf/dtf-plano-producao-e-roadmap.pdf`
> and in the weekly report (`Relatorio-Semana-1-DTF.docx`).
> The client-facing narrative lives in `docs/roadmap-cliente.pdf` and in the weekly
> report, which is a deliverable and is not versioned here.
>
> Update the **Current step** line and the item status every time something moves.
> Add new findings at the bottom of the relevant block rather than rewriting history.
@@ -410,10 +410,11 @@ charges. Fix as part of 1.1.
`smoke_test` failed on `/session`, blocking the release. Browser tests skip with
a warning when the runner has no Chrome — **install `google-chrome-stable` on the
runner (or set `CHROME_BIN`) to make them gate as well.**
- `[ ]` 5.2 — Remove or archive the dead prototypes `(F30)`: `portal/`, `kanban/`,
`agente/`, root `schema.sql` (~1,500 lines describing an abandoned model). Several
expose unauthenticated endpoints taking the acting user from the request body
(`/api/puxar`, `/api/devolver`).
- `[x]` 5.2 — `portal/`, `kanban/`, `agente/`, the root `schema.sql` and
`.env.exemplo` removed: 2,283 lines implementing a model this system abandoned,
referenced by nothing, with several endpoints taking the acting user from the
request body. The documents describing them are archived under `docs/historico/`
with a header saying they are background, not instructions.
- `[x]` 5.3 — Root `requirements.txt` deleted. It pinned by wildcard, listed
packages the system does not use, and sat next to the hash-locked
`local/requirements.lock` inviting the wrong one to be installed. Only historical
@@ -427,9 +428,16 @@ charges. Fix as part of 1.1.
`SECURITY_REPORT.md` describe a MinIO localhost stack, an API with "no external
network route", a `operator` / `local-operator-only` login the email-validated
model rejects, and a release gate — none match the current tree.
- `[ ]` 5.6 — `dtf-site.html` is 2,889 lines with commercial rules, the packing
engine, PDF analysis, UI and checkout inline `(F29)`. Split at least the pricing
table and the packer so 3.2 has somewhere to land.
- `[x]` 5.6 — The Site's 1,575-line inline script is now nine files under
`local/static/`, cut at the author's own section boundaries so no function was
split: config, modes, upload, sheet analysis, PDF, quality, packing, cart, flow.
`dtf-site.html` is 1,394 lines of markup and style. The extraction was verified
byte-identical before the tags were swapped in, and they load as classic scripts
in the original order, so evaluation semantics are unchanged.
A consequence worth having: with no inline script anywhere, the policy needs no
hash allowlist and is now simply `script-src 'self'`. `site-packing.js` is also
where a server-side packer (3.2) has to agree, which was the point of splitting.
- `[ ]` 5.7 — Commercial rules duplicated between `FAIXAS` (JS) and `TIERS` (Python)
`(F26)`. `test_pricing` guards parity; generate one from the other instead.
- `[ ]` 5.11 — Nothing tests the schema against an empty database. The 4.1 indexes

View File

@@ -1,141 +0,0 @@
"""
Agente da fábrica — traz a arte aprovada do portal para o servidor local.
Roda como serviço:
Windows: nssm install DtfAgente "C:\\Python312\\python.exe" "C:\\dtf\\agente.py"
Linux: systemd (ver dtf-agente.service no repositório)
Duas fontes de trabalho:
- webhook do portal (chega na hora)
- polling a cada 60 s (rede de segurança se o webhook falhar)
Se a internet cair, o agente para e o portal continua recebendo.
Quando voltar, ele baixa o acumulado. Nada se perde.
"""
from __future__ import annotations
import hashlib
import logging
import os
import sqlite3
import time
from datetime import datetime
from pathlib import Path
import httpx
PORTAL = os.environ["BASE_PORTAL"]
TOKEN = os.environ["AGENTE_TOKEN"]
RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF"))
BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db"))
INTERVALO = 60
HEARTBEAT = 300
PASTA_ENTRADA = RAIZ / "00_ARTE_RECEBIDA"
logging.basicConfig(
filename=RAIZ / "_log" / "agente.log",
level=logging.INFO,
format="%(asctime)s %(levelname)s %(message)s",
)
log = logging.getLogger("agente")
def sha256(caminho: Path) -> str:
h = hashlib.sha256()
with open(caminho, "rb") as f:
for bloco in iter(lambda: f.read(1 << 20), b""):
h.update(bloco)
return h.hexdigest()
def registrar_no_kanban(arte: dict, arquivos: list[Path]) -> None:
"""Insere o card. Idempotente: a chave é o arte_id, não o nome do arquivo."""
con = sqlite3.connect(BANCO)
try:
con.execute("""
CREATE TABLE IF NOT EXISTS card(
arte_id INTEGER PRIMARY KEY,
pedido TEXT, cliente TEXT, metros REAL,
coluna TEXT DEFAULT 'rec', desde TEXT,
maquina TEXT, partes INTEGER
)""")
con.execute("""
INSERT OR IGNORE INTO card(arte_id,pedido,cliente,metros,desde,partes)
VALUES (?,?,?,?,?,?)""",
(arte["arte_id"], arte["pedido"], arte["cliente"],
arte["metros"], datetime.now().isoformat(timespec="seconds"),
len(arquivos)))
con.commit()
finally:
con.close()
def baixar(cliente: httpx.Client, arte: dict) -> bool:
PASTA_ENTRADA.mkdir(parents=True, exist_ok=True)
salvos: list[Path] = []
for parte in arte["partes"]:
destino = PASTA_ENTRADA / f"{arte['pedido']}_{parte['nome']}"
if destino.exists():
salvos.append(destino)
continue
tmp = destino.with_suffix(".parcial")
with cliente.stream("GET", parte["url"]) as r:
r.raise_for_status()
with open(tmp, "wb") as f:
for bloco in r.iter_bytes(1 << 20):
f.write(bloco)
tmp.rename(destino) # só aparece na pasta quando está completo
salvos.append(destino)
log.info("baixado %s (%s) sha=%s", destino.name,
f"{parte['metros']:.2f} m", sha256(destino)[:12])
registrar_no_kanban(arte, salvos)
cliente.post(f"{PORTAL}/api/artes/{arte['arte_id']}/baixada",
headers={"x-token": TOKEN}, timeout=15)
return True
def ciclo(cliente: httpx.Client) -> int:
r = cliente.get(f"{PORTAL}/api/artes", headers={"x-token": TOKEN}, timeout=30)
r.raise_for_status()
artes = r.json()
for arte in artes:
try:
baixar(cliente, arte)
except Exception as e:
log.error("falha na arte %s: %s", arte["arte_id"], e)
return len(artes)
def main() -> None:
log.info("agente iniciado · portal=%s · pasta=%s", PORTAL, RAIZ)
ultimo_hb = 0.0
with httpx.Client(follow_redirects=True) as cliente:
while True:
try:
n = ciclo(cliente)
if n:
log.info("%d arte(s) processada(s)", n)
except httpx.HTTPError as e:
# internet caiu: não é erro fatal, o portal segue recebendo
log.warning("sem conexão com o portal: %s", e)
except Exception as e:
log.exception("erro inesperado: %s", e)
agora = time.time()
if agora - ultimo_hb > HEARTBEAT:
try:
cliente.post(f"{PORTAL}/api/agente/heartbeat",
headers={"x-token": TOKEN}, timeout=10)
ultimo_hb = agora
except Exception:
pass # sem sinal por 15 min, o portal alerta o TI
time.sleep(INTERVALO)
if __name__ == "__main__":
main()

View File

@@ -1,14 +0,0 @@
[Unit]
Description=Agente DTF - baixa artes do portal
After=network-online.target
[Service]
Type=simple
User=dtf
EnvironmentFile=/etc/dtf/.env
ExecStart=/usr/bin/python3 /opt/dtf/agente.py
Restart=always
RestartSec=15
[Install]
WantedBy=multi-user.target

15
docs/README.md Normal file
View File

@@ -0,0 +1,15 @@
# Documents
| File | What it is |
|---|---|
| `reuniao-2026-09-09-anotacoes.pdf` | Meeting notes that set the business direction: the bottleneck, the 24h goal, the automation and payment decisions. Still the record of what was asked for. |
| `roadmap-cliente.pdf` | The client-facing plan, as last sent. Generated by `tools/generate_dtf_report.py`; regenerate rather than edit. |
| `historico/` | The original specification and prototype documents. Background only — they describe a model this system does not implement. |
Current engineering documents live at the repository root: `CONTEXT.md` for how
the system works, `ROADMAP.md` for what is outstanding, `LOCAL_SETUP.md` to run
it, `PORTAINER.md` to deploy it, `SECURITY_REPORT.md` and `PRODUCTION_INPUTS.md`
for the security position and the decisions still owed by the client.
Weekly client reports are deliverables, not repository content. They are produced
for a specific week and are deliberately not versioned here.

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

@@ -0,0 +1,26 @@
# Historical documents
These describe the project as it was originally designed: a Tiny-first flow with
token upload links, a factory-side agent, a local SQLite Kanban, FlexiPRINT
automation, 90-day retention and 12-month artwork reuse.
**None of it describes the system that exists.** `CONTEXT.md` is the current
source of truth and `ROADMAP.md` tracks outstanding work. These are kept because
they record decisions and reasoning that the current documents do not repeat —
read them for background, never as instructions.
| File | What it was |
|---|---|
| `ESPECIFICACAO.md` | Original specification of the Tiny-first model |
| `API.md` | Endpoint sketch for the prototype portal |
| `TAREFAS.md` | Task plan written before the current scope |
| `IMPLEMENTATION_REPORT.md` | Status report for the prototype milestone |
| `dtf-organograma-*` | Organisation charts, referenced by nothing |
The prototype code they describe — `portal/`, `kanban/`, `agente/`, the root
`schema.sql` and `.env.exemplo` — was removed once nothing referenced it. Recover
it from Git history if it is ever needed:
```bash
git log --oneline --diff-filter=D -- portal kanban agente
```

File diff suppressed because it is too large Load Diff

View File

@@ -1,557 +0,0 @@
"""
Kanban da sala de DTF — roda no servidor da fábrica, rede interna.
Princípio: o KANBAN é a única interface. O operador move o card aqui e as
pastas do servidor seguem sozinhas — ninguém abre o Explorer.
Decidido em 30/08/2026: não existe watchdog de pastas. Movimento feito fora do
kanban não é suportado.
A fila é sempre por HORÁRIO DE CHEGADA: quem chegou primeiro fica em cima.
Mover o card grava o movimento, move o arquivo, enfileira o marcador no Tiny
e o WhatsApp ao cliente. Ninguém abre o Tiny para atualizar nada.
O log de movimentos é gravado SEMPRE, mesmo se o Tiny estiver fora do ar.
A medição de tempo por etapa nunca depende de API de terceiro.
Rodar:
uvicorn main:app --host 0.0.0.0 --port 8080
"""
from __future__ import annotations
import json
import os
import shutil
import sqlite3
import threading
import time
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import FastAPI, HTTPException
from fastapi.responses import FileResponse, RedirectResponse
from pydantic import BaseModel
import tiny
import whats
BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db"))
RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF"))
# O KANBAN é a fonte de verdade da operação. O marcador no Tiny existe só para
# quem NÃO abre o kanban: comercial atendendo cliente no telefone, expedição
# conferindo se o DTF saiu, e o Marcus olhando pelo celular fora da fábrica.
#
# Por isso o Tiny recebe o estágio grosso, não o detalhe fino:
# está sendo feito · travou · ficou pronto
#
# Três chamadas por pedido em vez de sete. A 213 pedidos/dia, são 640 requisições
# em vez de 1.500 — folga no limite da API e menos job para o TI monitorar.
COLUNAS = [
("aut", "Aguardando autorização", "05_AUTORIZACAO", None, False), # só retrabalho
("rec", "Arte recebida", "00_ARTE_RECEBIDA", None, False),
("tra", "Arte tratada", "10_ARTE_TRATADA", None, False),
("apc", "Aprovação de cor", "15_APROVACAO_COR", "DTF-PROVA-COR",False),
("fil", "Fila de impressão", "20_FILA_IMPRESSAO", None, False),
("imp", "Imprimindo", "30_IMPRIMINDO", "DTF-PRODUCAO", False),
("cor", "Correção", "40_CORRECAO", "CORRECAO DTF", False),
("fin", "Finalizado", "60_FINALIZADO", "DTF-PRONTO", False),
]
# A sala só IMPRIME o filme — quem aplica na peça é o cliente.
# Por isso não existe coluna de aplicação nem causa de retrabalho por aplicação.
IDS = [c[0] for c in COLUNAS]
INFO = {c[0]: c for c in COLUNAS}
AVISA_CLIENTE = {"apc": "prova_cor", "imp": "producao",
"cor": "correcao", "fin": "concluido"}
app = FastAPI(title="Kanban DTF")
# ---------------------------------------------------------------------------
# Distribuição entre as máquinas — POR PUXADA, não por empurro
# ---------------------------------------------------------------------------
# O sistema NÃO decide qual máquina roda qual pedido. A fila é única, por
# horário de chegada, e a máquina que termina puxa o próximo.
#
# Empurrar exigiria adivinhar qual máquina estará livre daqui a duas horas. Se
# uma travar, a fila dela para enquanto outra fica ociosa, e alguém remaneja na
# mão. Por puxada nunca desbalanceia.
#
# Para o operador: um botão "puxar próximo". O arquivo já vem com o spot pronto
# do robô — ele só abre no Flexi e roda.
# Ajustado em 02/09/2026 pelo Thales e Alexandre:
# "reserva de 15 min é demais, no máx 3 min"
# "pedido por vez" — não puxar lote de trabalho
RESERVA_MIN = 3 # sem entrar em Imprimindo nesse prazo, volta para a fila
PUXAR_ATE_MIN = 0 # 0 = um pedido por vez, como a sala pediu
class Puxar(BaseModel):
maquina: int
usuario: str
minutos: int = PUXAR_ATE_MIN
@app.post("/api/puxar")
def puxar(p: Puxar):
"""
A máquina puxa o próximo da fila. Reserva por 15 min e move o arquivo para
a hot folder daquela máquina — o FlexiPRINT processa sozinho de lá.
"""
agora = datetime.now()
limite = (agora - timedelta(minutes=RESERVA_MIN)).isoformat(timespec="seconds")
pegos, minutos = [], 0
with con() as c:
# solta reservas vencidas antes de distribuir
c.execute("UPDATE card SET reservado_por=NULL, reservado_em=NULL "
"WHERE reservado_em IS NOT NULL AND reservado_em < ?", (limite,))
fila = c.execute(
"SELECT * FROM card WHERE coluna='fil' AND reservado_por IS NULL "
"ORDER BY desde").fetchall() # sempre por horário de chegada
for card in fila:
# estimativa de máquina: o campo do card manda; sem ele, calcula pelos metros
mm = card["minutos_maquina"] or round(card["metros"] / 20 * 60)
if pegos: # um pedido por vez
break
c.execute("UPDATE card SET reservado_por=?, reservado_em=?, maquina=? "
"WHERE arte_id=?",
(f"Maq {p.maquina}", agora.isoformat(timespec="seconds"),
f"Maq {p.maquina}", card["arte_id"]))
mover_arquivos(card["pedido"], "20_FILA_IMPRESSAO", f"30_MAQ{p.maquina}")
pegos.append({"pedido": card["pedido"], "metros": card["metros"],
"minutos": mm})
minutos += mm
return {"maquina": f"Maq {p.maquina}", "pedidos": pegos,
"minutos_total": minutos,
"reserva_expira_em": RESERVA_MIN}
@app.post("/api/devolver")
def devolver(arte_id: int, usuario: str):
"""
Máquina travou no meio. O pedido volta ao TOPO da fila, não ao fim —
ele já esperou uma vez.
"""
with con() as c:
card = c.execute("SELECT * FROM card WHERE arte_id=?", (arte_id,)).fetchone()
if not card:
raise HTTPException(404)
maq = (card["maquina"] or "Maq 1").replace("Maq ", "")
mover_arquivos(card["pedido"], f"30_MAQ{maq}", "20_FILA_IMPRESSAO")
# desde antigo = volta para o topo da ordem por horário de chegada
c.execute("UPDATE card SET coluna='fil', reservado_por=NULL, "
"reservado_em=NULL, maquina=NULL WHERE arte_id=?", (arte_id,))
c.execute("""INSERT INTO movimento
(arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina)
VALUES (?,?,?,?,?,?,?,?,?)""",
(arte_id, card["pedido"], "imp", "fil", card["desde"],
datetime.now().isoformat(timespec="seconds"), 0, usuario, card["maquina"]))
return {"ok": True, "voltou_ao_topo": True}
# ---------------------------------------------------------------------------
# Aprovação de cor — evita o retrabalho em vez de repor depois
# ---------------------------------------------------------------------------
# Imprime uma amostra pequena, fotografa e manda ao cliente. Ele aprova, e só
# então o pedido vai para a fila. Custa centímetros de filme e evita metros.
#
# Dispara sozinho em três situações:
PROVA_METROS = 10.0 # arquivo grande: erro de cor custa caro
PROVA_CLIENTE_NOVO = True # primeiro pedido do cliente
# cores fora do gamut CMYK, que quase nunca saem como o cliente vê na tela
PROVA_CORES_TENSAS = {
"vermelho_saturado", "laranja", "verde_vivo", "azul_royal",
"roxo", "tom_de_pele", "cinza_neutro",
}
def precisa_prova_cor(metros: float, cliente_novo: bool,
cores_detectadas: set[str]) -> tuple[bool, str]:
if metros >= PROVA_METROS:
return True, f"arquivo de {metros:.1f} m"
if cliente_novo and PROVA_CLIENTE_NOVO:
return True, "primeiro pedido do cliente"
tensas = cores_detectadas & PROVA_CORES_TENSAS
if tensas:
return True, "cor fora do gamut: " + ", ".join(sorted(tensas))
return False, ""
# ---------------------------------------------------------------------------
# Retrabalho — SKU CRRMP.TX.100CM
# ---------------------------------------------------------------------------
# A causa é obrigatória e define quem absorve o custo. Sem ela o card não anda.
CAUSAS = {
"arte_cliente": ("Arte ruim aprovada pelo cliente", "cliente"),
"tratamento": ("Erro de tratamento", "casa"),
"impressao": ("Falha de impressão", "casa"),
"perfil_cor": ("Perfil de cor", "casa"),
"pedido": ("Erro de pedido", "casa"),
}
# Quem abre e quem autoriza são pessoas diferentes, de propósito.
#
# MAYANA abre e CLASSIFICA a causa. Ela conhece o cliente e sabe se a
# reclamação procede. A causa fica TRAVADA depois de aberta.
#
# THALES ou ALEXANDRE autorizam. O retrabalho da casa entra na meta e na
# remuneração deles — então têm incentivo real para questionar o que não
# procede. É controle natural, não burocracia.
#
# Por que a causa não pode ser mudada por quem autoriza: com o indicador
# atrelado a bônus, existiria incentivo para reclassificar falha de máquina
# como "arte do cliente". Quem discorda CONTESTA, e a contestação fica
# registrada com quem pediu e quem decidiu.
ABRE = "mayana"
AUTORIZA = ("thales", "alexandre")
# Alçada por metragem. Cada metro custa R$ 4,94 de insumo mais tempo de máquina.
ALCADA = [(15.0, "sala"), (float("inf"), "financeiro")]
# Meta por causa, não meta única. Cada um responde pelo que controla.
# 0,4% em cada uma das quatro causas da casa = 1,6% no total.
#
# Perfil de cor é causa PRÓPRIA, separada de falha de impressão. Cor errada é o
# retrabalho mais comum de DTF e quase nunca é defeito de máquina: ou a arte
# veio em CMYK, ou o monitor do cliente não é calibrado, ou o perfil da
# impressora está desatualizado. Só o terceiro é da casa — e separando dá para
# saber quanto é cada coisa.
META_POR_CAUSA = {
"impressao": (0.4, "Thales e Alexandre"),
"perfil_cor": (0.4, "Thales e Alexandre"),
"tratamento": (0.4, "designers"),
"pedido": (0.4, "comercial"),
"arte_cliente": (None, None), # reposição comercial: fora da meta
}
# soma das metas da casa = 1,6%
TETO_MES_PCT = 6.0 # acima disso tudo sobe uma alçada
TETO_CLIENTE_PCT = 10.0 # cliente acima disso fica sinalizado no painel
def quem_autoriza(metros: float, vez: int, pct_mes: float) -> str:
"""Reincidência e teto do mês sobem a alçada automaticamente."""
if vez >= 3:
return "diretoria"
nivel = next(n for lim, n in ALCADA if metros <= lim)
if vez == 2:
nivel = {"sala": "financeiro"}.get(nivel, "diretoria")
if pct_mes > TETO_MES_PCT:
nivel = {"sala": "financeiro", "financeiro": "diretoria"}.get(nivel, "diretoria")
return nivel
def conta_na_meta(causa: str) -> bool:
"""Só o retrabalho da casa entra na meta. O do cliente, não."""
return CAUSAS[causa][1] == "casa"
@app.get("/api/relatorio/retrabalho")
def retrabalho(dias: int = 30):
"""
Retrabalho por causa, com responsável e meta.
Base para a bonificação: cada equipe é medida só pelo que controla.
"""
with con() as c:
total = c.execute(
"SELECT COUNT(*) n FROM card WHERE desde >= date('now', ?)",
(f"-{dias} days",)).fetchone()["n"] or 1
linhas = c.execute("""
SELECT causa, COUNT(*) n, SUM(metros) m
FROM retrabalho WHERE aberto_em >= date('now', ?)
GROUP BY causa""", (f"-{dias} days",)).fetchall()
saida, casa = [], 0.0
for r in linhas:
pct = r["n"] / total * 100
meta, quem = META_POR_CAUSA.get(r["causa"], (None, None))
if conta_na_meta(r["causa"]):
casa += pct
saida.append({
"causa": r["causa"], "descricao": CAUSAS[r["causa"]][0],
"responsavel": quem, "pedidos": r["n"], "metros": r["m"],
"pct": round(pct, 2), "meta": meta,
"bate": None if meta is None else pct <= meta,
})
return {"por_causa": saida, "total_casa_pct": round(casa, 2),
"meta_casa_pct": round(sum(m for m, _ in META_POR_CAUSA.values()
if m is not None), 2)}
# --------------------------------------------------------------------------
def con():
c = sqlite3.connect(BANCO, timeout=10)
c.row_factory = sqlite3.Row
return c
def criar_tabelas() -> None:
with con() as c:
c.executescript("""
CREATE TABLE IF NOT EXISTS card(
arte_id INTEGER PRIMARY KEY, pedido TEXT, cliente TEXT, metros REAL,
coluna TEXT DEFAULT 'rec', desde TEXT, maquina TEXT, partes INTEGER,
reservado_por TEXT, reservado_em TEXT,
minutos_maquina INTEGER); -- estimativa; herda os marcadores 30min/1h/3h
CREATE TABLE IF NOT EXISTS movimento(
id INTEGER PRIMARY KEY AUTOINCREMENT,
arte_id INTEGER, pedido TEXT, de TEXT, para TEXT,
entrou_em TEXT, saiu_em TEXT, segundos INTEGER,
usuario TEXT, maquina TEXT);
CREATE TABLE IF NOT EXISTS job(
id INTEGER PRIMARY KEY AUTOINCREMENT,
tipo TEXT, payload TEXT, tentativas INTEGER DEFAULT 0,
proxima_em REAL, erro TEXT, feito INTEGER DEFAULT 0);
CREATE INDEX IF NOT EXISTS ix_mov_arte ON movimento(arte_id);
CREATE INDEX IF NOT EXISTS ix_job_fila ON job(feito, proxima_em);
""")
criar_tabelas()
# --------------------------------------------------------------------------
class Mover(BaseModel):
arte_id: int
para: str
usuario: str
maquina: str | None = None
@app.get("/api/quadro")
def quadro():
agora = datetime.now()
with con() as c:
cards = c.execute("SELECT * FROM card ORDER BY desde").fetchall()
saida = {i: [] for i in IDS}
for r in cards:
desde = datetime.fromisoformat(r["desde"])
saida[r["coluna"]].append({
"arte_id": r["arte_id"], "pedido": r["pedido"], "cliente": r["cliente"],
"metros": r["metros"], "maquina": r["maquina"], "partes": r["partes"],
"desde": r["desde"],
"parado_seg": int((agora - desde).total_seconds()),
"minutos_maquina": round(r["metros"] / 20 * 60), # 20 m/h por máquina
})
return {"colunas": [{"id": c[0], "nome": c[1]} for c in COLUNAS], "cards": saida}
@app.post("/api/mover")
def mover(m: Mover):
if m.para not in IDS:
raise HTTPException(400, "coluna inválida")
agora = datetime.now()
with con() as c:
card = c.execute("SELECT * FROM card WHERE arte_id=?", (m.arte_id,)).fetchone()
if not card:
raise HTTPException(404, "card não encontrado")
if card["coluna"] == m.para:
return {"ok": True, "sem_mudanca": True}
de = card["coluna"]
desde = datetime.fromisoformat(card["desde"])
# 1. o movimento é gravado ANTES de qualquer integração externa
c.execute("""INSERT INTO movimento
(arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina)
VALUES (?,?,?,?,?,?,?,?,?)""",
(m.arte_id, card["pedido"], de, m.para, card["desde"],
agora.isoformat(timespec="seconds"),
int((agora - desde).total_seconds()), m.usuario,
m.maquina or card["maquina"]))
c.execute("UPDATE card SET coluna=?, desde=?, maquina=COALESCE(?,maquina) "
"WHERE arte_id=?",
(m.para, agora.isoformat(timespec="seconds"), m.maquina, m.arte_id))
# 2. arquivo acompanha o card
mover_arquivos(card["pedido"], INFO[de][2], INFO[m.para][2])
# 3. jobs: marcador no Tiny e WhatsApp
# a máquina fica só no kanban — no Tiny basta saber que entrou em produção
marcador = INFO[m.para][3]
if marcador:
enfileirar(c, "tiny_marcador",
{"pedido": card["pedido"], "marcador": marcador})
if m.para in AVISA_CLIENTE:
enfileirar(c, "whats",
{"pedido": card["pedido"], "tipo": AVISA_CLIENTE[m.para]})
return {"ok": True, "de": de, "para": m.para}
def mover_arquivos(pedido: str, pasta_de: str, pasta_para: str) -> None:
origem, destino = RAIZ / pasta_de, RAIZ / pasta_para
destino.mkdir(parents=True, exist_ok=True)
for f in origem.glob(f"{pedido}_*"):
shutil.move(str(f), str(destino / f.name))
# --------------------------------------------------------------------------
# Fila de jobs — tabela simples, sem Redis nem Celery
# --------------------------------------------------------------------------
def enfileirar(c, tipo: str, payload: dict) -> None:
c.execute("INSERT INTO job(tipo,payload,proxima_em) VALUES (?,?,?)",
(tipo, json.dumps(payload), time.time()))
ESPERA = [60, 300, 1800] # 1 min, 5 min, 30 min
def worker() -> None:
while True:
try:
with con() as c:
job = c.execute(
"SELECT * FROM job WHERE feito=0 AND proxima_em<=? "
"ORDER BY id LIMIT 1", (time.time(),)).fetchone()
if not job:
time.sleep(3)
continue
p = json.loads(job["payload"])
try:
if job["tipo"] == "tiny_marcador":
tiny.marcador(p["pedido"], p["marcador"])
elif job["tipo"] == "whats":
whats.avisar(p["pedido"], p["tipo"])
c.execute("UPDATE job SET feito=1 WHERE id=?", (job["id"],))
except Exception as e:
n = job["tentativas"] + 1
if n > len(ESPERA):
c.execute("UPDATE job SET feito=1, erro=? WHERE id=?",
(f"desistiu: {e}", job["id"]))
# TODO: alertar o TI
else:
c.execute(
"UPDATE job SET tentativas=?, proxima_em=?, erro=? WHERE id=?",
(n, time.time() + ESPERA[n - 1], str(e), job["id"]))
except Exception:
time.sleep(5)
threading.Thread(target=worker, daemon=True).start()
# --------------------------------------------------------------------------
# QR do carimbo aponta para cá
# --------------------------------------------------------------------------
@app.get("/p/{pedido}")
def abrir_card(pedido: str):
"""A revisão bipa o QR do filme e cai direto no card."""
with con() as c:
card = c.execute("SELECT arte_id FROM card WHERE pedido=?", (pedido,)).fetchone()
if not card:
raise HTTPException(404, "pedido não encontrado no kanban")
return RedirectResponse(f"/?card={card['arte_id']}")
@app.get("/api/pedido/{pedido}")
def trilha(pedido: str):
"""Tempo em cada etapa e quanto do total foi só esperando."""
PARADO = {"rec", "tra", "fil", "cor"}
with con() as c:
movs = c.execute(
"SELECT * FROM movimento WHERE pedido=? ORDER BY id", (pedido,)).fetchall()
card = c.execute("SELECT * FROM card WHERE pedido=?", (pedido,)).fetchone()
if not card:
raise HTTPException(404)
agora = int((datetime.now() - datetime.fromisoformat(card["desde"])).total_seconds())
etapas = [{"coluna": m["de"], "nome": INFO[m["de"]][1], "segundos": m["segundos"]}
for m in movs]
etapas.append({"coluna": card["coluna"], "nome": INFO[card["coluna"]][1],
"segundos": agora, "atual": True})
total = sum(e["segundos"] for e in etapas)
esperando = sum(e["segundos"] for e in etapas if e["coluna"] in PARADO)
return {"pedido": pedido, "cliente": card["cliente"], "metros": card["metros"],
"etapas": etapas, "total_seg": total, "esperando_seg": esperando}
PERIODOS = {"hoje": 1, "7": 7, "30": 30, "mes": 30, "ant": 60}
@app.get("/api/relatorio/impressao")
def tempo_impressao(dias: int = 7):
"""
Tempo médio de impressão e velocidade real por máquina.
Sai da coluna 'imp' da tabela movimento, cruzada com os metros do card.
É este número que diz se 20 m/h é a velocidade real ou só a de catálogo.
"""
with con() as c:
linhas = c.execute("""
SELECT m.maquina, COUNT(*) n,
AVG(m.segundos) media_seg,
SUM(c.metros) metros,
SUM(m.segundos) total_seg
FROM movimento m JOIN card c ON c.arte_id = m.arte_id
WHERE m.de = 'imp' AND m.saiu_em >= date('now', ?)
GROUP BY m.maquina""", (f"-{dias} days",)).fetchall()
saida = []
for r in linhas:
horas = (r["total_seg"] or 0) / 3600
saida.append({
"maquina": r["maquina"], "pedidos": r["n"],
"media_min": round((r["media_seg"] or 0) / 60),
"metros": round(r["metros"] or 0, 1),
"m_por_hora": round((r["metros"] or 0) / horas, 1) if horas else None,
})
return saida
# Depósito do Tiny para onde o insumo é transferido antes de imprimir.
# A Altus também VENDE insumo, então esse depósito separa o que é consumo
# interno do que é revenda. Confirmar o nome exato no cadastro do Tiny.
DEPOSITO_IMPRESSAO = "Sala DTF"
SKU_FILME = "DTF.FILME.57"
@app.get("/api/relatorio/aproveitamento")
def aproveitamento(dias: int = 30):
"""
Quanto do filme transferido virou metro faturado.
Compra-se rolo de 100 m e imprime-se cerca de 90, por acerto de máquina,
prova de cor, refile e sobra de ponta. Cada ponto percentual vale cerca de
R$ 980/mês no volume atual.
NÃO exige apontamento novo na sala: o consumo já é registrado hoje, quando
o insumo é transferido para o depósito de impressão no Tiny. Este endpoint
só lê a movimentação desse depósito.
"""
fat = 0.0
with con() as c:
fat = c.execute(
"SELECT SUM(metros) m FROM card WHERE coluna='fin' AND desde >= date('now', ?)",
(f"-{dias} days",)).fetchone()["m"] or 0
cons = tiny.transferido_para_deposito(
sku=SKU_FILME, deposito=DEPOSITO_IMPRESSAO, dias=dias)
pct = fat / cons * 100 if cons else None
return {"metros_faturados": round(fat, 1),
"metros_de_filme": round(cons, 1),
"fonte": f"transferências para o depósito {DEPOSITO_IMPRESSAO} no Tiny",
"aproveitamento_pct": round(pct, 1) if pct else None,
"valor_do_ponto_mes": 980}
@app.get("/api/relatorio/etapas")
def relatorio(dias: int = 7):
"""Tempo médio por etapa. É o número que hoje não existe em lugar nenhum."""
with con() as c:
linhas = c.execute("""
SELECT de, COUNT(*) n, AVG(segundos) media, MAX(segundos) pior
FROM movimento
WHERE saiu_em >= date('now', ?)
GROUP BY de""", (f"-{dias} days",)).fetchall()
return [{"coluna": r["de"], "nome": INFO[r["de"]][1], "movimentos": r["n"],
"media_min": round(r["media"] / 60), "pior_min": round(r["pior"] / 60)}
for r in linhas]
@app.get("/")
def index():
return FileResponse("static/kanban.html")

View File

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

View File

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

View File

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

View File

@@ -24,9 +24,14 @@ const server=createServer(async(req,res)=>{
res.setHeader('Content-Security-Policy',`default-src 'self'; script-src 'self' ${hashes.join(' ')}; script-src-attr 'none'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; connect-src 'self'; object-src 'none'`);
res.end(html);return;
}
if(['/privacy.js','/upload.js','/checkout.js','/cart.js'].includes(req.url)){
res.setHeader('Content-Type','text/javascript');
res.end(await readFile('local/static'+req.url));return;
// Serve any script the page asks for, resolved inside local/static, rather than
// a hardcoded list: the Site's behaviour is split across several files and a
// list would silently 404 the next one added.
if(/^\/[\w.-]+\.js$/.test(req.url)){
try{
const body=await readFile(resolve('local/static'+req.url));
res.setHeader('Content-Type','text/javascript');res.end(body);return;
}catch{ res.writeHead(404);res.end();return; }
}
res.writeHead(404);res.end();
});

View File

@@ -1,15 +1,26 @@
"""Hash only trusted source inline scripts at image build time for strict CSP."""
"""Compile the gateway configuration: hash any trusted inline script for the CSP.
The Site's behaviour now lives in separate files, so normally there is nothing to
hash and the policy is simply script-src 'self' — no allowlist to get wrong. The
hashing stays because an inline script added later must not silently need
'unsafe-inline'; it is hashed automatically instead.
"""
import base64
import hashlib
import os
from pathlib import Path
import re
root=Path('/build')
hashes=[]
for html in [root/'dtf-site.html',*(root/'local/static').glob('*.html')]:
for attributes,body in re.findall(r'<script\b([^>]*)>(.*?)</script>',html.read_text(),re.S|re.I):
if not re.search(r'\bsrc\s*=',attributes,re.I):
hashes.append("'sha256-"+base64.b64encode(hashlib.sha256(body.encode()).digest()).decode()+"'")
template=Path(os.environ.get('NGINX_TEMPLATE', root/'local/nginx.conf.template')).read_text()
(root/'default.conf.template').write_text(template.replace('@SCRIPT_HASHES@',' '.join(hashes)))
root = Path('/build')
hashes = []
for html in [root / 'dtf-site.html', *(root / 'local/static').glob('*.html')]:
for attributes, script in re.findall(r'<script\b([^>]*)>(.*?)</script>', html.read_text(), re.S | re.I):
if not re.search(r'\bsrc\s*=', attributes, re.I) and script.strip():
hashes.append("'sha256-" + base64.b64encode(hashlib.sha256(script.encode()).digest()).decode() + "'")
template = Path(os.environ.get('NGINX_TEMPLATE', root / 'local/nginx.conf.template')).read_text()
rendered = template.replace('@SCRIPT_HASHES@', ' '.join(hashes))
# Collapse the gap an empty hash list leaves behind, so the policy reads cleanly.
rendered = re.sub(r"(script-src 'self')\s+;", r'\1;', rendered)
(root / 'default.conf.template').write_text(rendered)
print(f'CSP script-src: {len(hashes)} inline hash(es)')

View File

@@ -21,7 +21,14 @@ def run():
headers=raw('/',200)
policy=headers['Content-Security-Policy']
assert "script-src-attr 'none'" in policy and "frame-ancestors 'none'" in policy
assert "'sha256-" in policy and "object-src 'none'" in policy
# The Site has no inline script, so the policy needs no hash allowlist at all.
# Assert the property that matters rather than the mechanism: nothing inline
# executes, and any hash that does appear was added deliberately at build time.
script_src = next(d.strip() for d in policy.split(';') if d.strip().startswith('script-src '))
assert "'unsafe-inline'" not in script_src and "'unsafe-eval'" not in script_src, script_src
assert script_src == "script-src 'self'", script_src
assert "object-src 'none'" in policy
assert 'cdnjs' not in policy, 'pdf.js is vendored; no CDN belongs in the policy'
raw('/api/health',400,{'Host':'attacker.invalid'})
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'https://attacker.invalid'},b'{}')
raw('/api/account/logout',403,{'Content-Type':'application/json','Origin':'http://localhost:9999'},b'{}')

77
local/static/site-cart.js Normal file
View File

@@ -0,0 +1,77 @@
/* Site DTF — Cart totals and the order summary.
Extracted verbatim from the single inline script in dtf-site.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── carrinho
let itemAtual=null;
function limpaPaineis(){
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
if(!pedido.length){ $('carr').classList.remove('on'); }
}
// O carrinho junta itens de produtos diferentes e cobra uma vez só.
function pintaPedido(){
$('itens').innerHTML = pedido.map((it,i)=>
'<div class="item"><div class="nm2"><b>'+escapeHTML(it.tit)+'</b><span>'+escapeHTML(it.desc)+'</span></div>'+
'<div class="vl2">'+rs(it.total)+'</div>'+
'<button class="x" data-rmi="'+i+'" aria-label="Remover">×</button></div>').join('');
$('itens').querySelectorAll('[data-rmi]').forEach(b=>b.addEventListener('click',()=>{
pedido.splice(+b.dataset.rmi,1); pintaPedido();
}));
const itens = pedido.reduce((t,it)=>t+it.total,0) + (itemAtual? itemAtual.total : 0);
const frete = entrega.tipo==='frete' && entrega.cotado ? entrega.valor : 0;
const soma = itens + frete;
const n = pedido.length + (itemAtual?1:0);
const eco = [...pedido, ...(itemAtual?[itemAtual]:[])]
.reduce((t,it)=>t+(TABELA[it.modo]-it.unit)*it.cob, 0);
$('resLin').innerHTML =
'<div class="l"><span>'+n+(n===1?' item':' itens')+'</span><b>'+rs(itens+eco)+'</b></div>'+
(eco>0.01? '<div class="l" style="color:var(--verde-ml)"><span>Desconto pela nota</span>'+
'<b style="color:var(--verde-ml)">− '+rs(eco)+'</b></div>' : '')+
'<div class="l"><span>Frete</span><b>'+
(entrega.tipo==='retira' ? '<span style="color:var(--verde-ml)">Grátis</span>'
: entrega.cotado ? rs(frete) : 'a cotar')+'</b></div>'+
'<div class="l"><span>Revisão e reencaixe</span>'+
'<b><span style="color:var(--verde-ml)">Grátis</span></b></div>';
$('carrTot').textContent=rs(soma);
$('bPagar').textContent = (pedido.length + (itemAtual?1:0))>1
? 'Pagar os '+(pedido.length+(itemAtual?1:0))+' itens de uma vez'
: 'Ir para o pagamento';
if(pedido.length || itemAtual) $('carr').classList.add('on');
if($('cartLink')) $('cartLink').textContent='Carrinho ('+n+')';
window.dispatchEvent(new Event('dtf-cart-changed'));
}
function carrinho(){
const base=precoBase(nota), cob=cobrar(metros);
const unit=base, tot=cob*unit, sobra=+(cob-metros).toFixed(2);
$('carr').classList.add('on');
let l='<div class="l"><span>'+MODOS[modo].tit+'</span><b>'+rs(base)+'/m</b></div>';
{
const teto=TABELA[modo], eco=(teto-base)*cob;
l = '<div class="l"><span>'+MODOS[modo].tit+' · tabela</span><b>'+rs(teto)+'/m</b></div>';
l += eco>0.01
? '<div class="l" style="color:#7FD4A0"><span>Desconto pela nota '+nota+'</span><b>−'+rs(eco)+'</b></div>'
: '<div class="l" style="color:#8D9AB0"><span>Nota '+nota+' · sem desconto</span><b>—</b></div>';
}
if(!ehFolha()) l += '<div class="l" style="color:#8D9AB0"><span>Montagem da folha</span>'+
'<b>inclusa · '+rs(MONTAGEM[modo])+'/m</b></div>';
l += '<div class="l"><span>Metragem'+(sobra>0.001?' · arredondada para 10 cm':'')+
'</span><b>'+fmtM(cob)+' m</b></div>';
l += '<div class="l"><span>Preço final do metro</span><b>'+rs(unit)+'</b></div>'+
'<div class="l" style="color:var(--laranja2)"><span>Revisão e reencaixe</span><b>grátis</b></div>';
$('carrLin').innerHTML=l;
$('atual').style.display='';
itemAtual={modo, tit:MODOS[modo].tit, nota, metros, cob, unit, total:tot,
localFiles:(ehFolha()?folhas:artes).map(x=>x.f),
desc:fmtM(cob)+' m · '+rs(unit)+'/m · nota '+nota};
pintaPedido();
previa();
if(nota<90){
const piso=pisoEscada(), ganho=(base-piso)*cob;
if(ganho>0.5) qm('<b>Dá para chegar ao melhor preço.</b> Corrigindo os pontos e chegando a 90, '+
'o metro cai para '+rs(piso)+' — são '+rs(ganho)+' a menos neste pedido.','var(--verde)');
}
}
function qm(t,c){ const m=$('qmsg'); m.style.display='block'; m.style.color=c||'var(--verde)'; m.innerHTML=t; }

108
local/static/site-config.js Normal file
View File

@@ -0,0 +1,108 @@
/* Site DTF — Product tables, commercial ladders, shared state and field validation.
Extracted verbatim from the single inline script in dtf-site.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
const $=id=>document.getElementById(id);
const rs=v=>'R$ '+v.toFixed(2).replace('.',',');
const fmt=b=>b<1048576?(b/1024).toFixed(0)+' KB':(b/1048576).toFixed(1).replace('.',',')+' MB';
// ── modos
const MODOS={
file: {tit:'Arquivo por metro', lg:'DTF têxtil · 57 cm', larg:57,
preco:'tabela R$ 19,90/m · até R$ 14,90', multi:true, folha:true,
sub:'Sua folha montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
zt:'Arraste suas folhas montadas',
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
avulsa:{tit:'Artes avulsas', lg:'DTF têxtil · 57 cm', larg:57,
preco:'tabela R$ 29,90/m · até R$ 24,90', multi:true,
sub:'Cada arte num arquivo. Montamos a folha com o melhor encaixe.',
zt:'Arraste suas artes aqui',
zs:'PNG ou JPG com fundo transparente · informe a largura de cada uma'},
uvfile:{tit:'Arquivo por metro · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5,
preco:'tabela R$ 85,90/m · até R$ 69,90', multi:true, folha:true,
sub:'Sua folha de UV montada. Revisamos, corrigimos e reencaixamos antes de imprimir.',
zt:'Arraste suas folhas de UV',
zs:'uma ou várias · PNG, JPG, TIFF ou PDF · PSD, AI e CDR passam por tratamento'},
uv: {tit:'Artes avulsas · UV', lg:'Adesivo UV · 28,5 cm', larg:28.5,
preco:'tabela R$ 99,90/m · até R$ 83,90', multi:true,
sub:'Cada arte de UV num arquivo. Montamos a folha com o melhor encaixe.',
zt:'Arraste suas artes de UV',
zs:'PNG ou JPG com fundo transparente · informe a largura de cada arte'}
};
// Quatro escadas. A de artes avulsas e a da folha pronta mais a montagem:
// R$ 10,00 no têxtil, R$ 14,00 no UV, constante em todas as faixas.
const FAIXAS={
file: [[90,14.90],[75,16.20],[60,17.50],[40,18.70],[0,19.90]],
avulsa:[[90,24.90],[75,26.20],[60,27.50],[40,28.70],[0,29.90]],
uvfile:[[90,69.90],[75,73.90],[60,77.90],[40,81.90],[0,85.90]],
uv: [[90,83.90],[75,87.90],[60,91.90],[40,95.90],[0,99.90]]
};
const TABELA={file:19.90, avulsa:29.90, uvfile:85.90, uv:99.90}; // teto de cada escada
const MONTAGEM={avulsa:10.00, uv:14.00}; // o que a montagem soma sobre a folha pronta
const ehFolha=m=>!!MODOS[m||modo].folha;
const pisoEscada=m=>FAIXAS[m||modo][0][1];
const MINIMO_M=1, GAP_CM=0.5, RODAPE_CM=5;
const RENDERIZA=/[.](png|jpe?g|webp|gif|bmp)$/i;
// Folha pronta: dá para medir PNG, JPG e PDF. PSD, AI, TIFF e CDR entram, mas
// o navegador não abre — o cliente informa o comprimento e o arquivo passa por
// tratamento antes de imprimir.
const ACEITA={
folha: /[.](png|jpe?g|tiff?|pdf|psd|psb|ai|cdr)$/i,
avulsa: /[.](png|jpe?g|webp)$/i
};
const MEDE=/[.](png|jpe?g|webp|gif|bmp|pdf)$/i;
const DPI_RECUSA=96, DPI_AVISA=300; // régua dos designers · 02/09/2026
// A especificação do PDF limita a página a 14.400 unidades — 200 pol, 508 cm.
// Passar disso só é válido com UserUnit (PDF 1.6+), que a maioria dos programas
// não escreve: o arquivo sai fora do padrão e só o gerador lê direito.
const PDF_MAX_PT=14400, PT_CM=2.54/72;
const extDe=n=>(n.split('.').pop()||'').toUpperCase();
let CLIENTE='SEU NOME AQUI', PEDIDO='000000';
let modo=null, artes=[], recemChegada=null, metros=0, nota=0, reencaixado=false;
let folhas=[]; // [{f, med, rep, m}] · o cliente pode subir várias
const folhaTotalM=()=>folhas.reduce((t,x)=>t+(x.m||0)*(x.rep||1),0);
// O cliente declara o que está mandando escolhendo o produto, em vez de o site
// adivinhar pelo formato do arquivo. A declaração de folha montada é conferida na
// medição: uma imagem sem pixel para ocupar a largura do filme não é uma folha.
// Cada modo por metro tem o seu par de artes avulsas, e o seletor anda nos dois sentidos.
const PAR={file:'avulsa', avulsa:'file', uvfile:'uv', uv:'uvfile'};
let montagemCm=0; // altura da folha montada pelo motor, em cm
const ZOOMS=[1,1.5,2,3,4]; let zoomI=0; // ampliação da montagem ao vivo
let pedido=[]; // itens já fechados · o pagamento é um só
// A escolha de entrega vale para o pedido inteiro, não por item. No Tiny, retirada
// grava forma_envio X, transportador DropStar e frete por conta do remetente — igual
// ao que a expedição já recebe hoje, sem criar processo novo.
let entrega={tipo:'retira', cep:'', valor:0, cotado:true};
let cliente={cnpj:'', zap:'', mail:''};
let caminho='auto'; // o que o cliente diz que vai mandar
// CNPJ com os dois dígitos verificadores · máscara sem conta não vale nada
function cnpjOk(v){
const d=(v||'').replace(/\D/g,'');
if(d.length!==14 || /^(\d)\1{13}$/.test(d)) return false;
const calc=(base,pesos)=>{
const soma=base.split('').reduce((t,n,i)=>t+ +n*pesos[i],0);
const r=soma%11; return r<2? 0 : 11-r;
};
const p1=[5,4,3,2,9,8,7,6,5,4,3,2], p2=[6,...p1];
return calc(d.slice(0,12),p1)===+d[12] && calc(d.slice(0,13),p2)===+d[13];
}
const zapOk=v=>{ const d=(v||'').replace(/\D/g,''); return d.length===10||d.length===11; };
const mailOk=v=>/^[^\s@]+@[^\s@]+\.[a-z]{2,}$/i.test((v||'').trim());
const clienteOk=()=>cnpjOk(cliente.cnpj)&&zapOk(cliente.zap)&&mailOk(cliente.mail);
const larguraFilme=()=>MODOS[modo].larg;
// cobrança proporcional: arredonda para cima em 10 cm · 2,75 m vira 2,80 m
const cobrar=m=>Math.max(MINIMO_M, Math.ceil(m*10)/10);
const fmtM=m=>m.toFixed(2).replace('.',',');
const precoBase=n=>
(FAIXAS[modo].find(f=>n>=f[0])||FAIXAS[modo][FAIXAS[modo].length-1])[1];
const descontoMetragem=()=>0; // sem progressão por quantidade
const cls=n=>n>=85?'ok':n>=50?'av':'er';
const dpiDe=a=>a.cm>0? a.px/(a.cm/2.54) : 0;
// girar 90° inverte a proporção · espelhar não muda medida, só o desenho
const propDe=a=>{ const p=a.prop||1; return a.giro? 1/p : p; };
const TAMANHOS={57:[10,15,20,25,28.2], 28.5:[5,8,10,14,28.2]};
const px=f=>Math.round(Math.min(6000,Math.max(600,Math.sqrt(f.size/1024)*95)));

173
local/static/site-flow.js Normal file
View File

@@ -0,0 +1,173 @@
/* Site DTF — Carousel, path selector, delivery choice and input masks.
Extracted verbatim from the single inline script in dtf-site.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── decisões
// Remanejar conserta encaixe, não resolução — e a nota mede só resolução.
// Por isso a nota NÃO muda aqui. E a metragem nova só existe depois que o motor
// roda na folha de verdade, no servidor: o navegador recebe a folha fechada,
// não as artes separadas, então não há como recalcular aqui sem inventar número.
$('qOk').addEventListener('click',()=>{
if($('qOk').disabled) return;
const ok=$('ciente').classList.contains('on');
qm(ok? '<b>Registrado.</b> Você confirmou que viu a ressalva de resolução.'
: '<b>Certo.</b> Seu pedido está montado abaixo.','var(--tx)');
$('carr').scrollIntoView({behavior:'smooth',block:'nearest'});
});
$('pOk').addEventListener('click',()=>$('carr').scrollIntoView({behavior:'smooth'}));
$('pMais').addEventListener('click',()=>$('foco').scrollIntoView({behavior:'smooth'}));
$('pVolta').addEventListener('click',()=>{
folhas=[]; artes=[]; reencaixado=false; pintaFolha(); pintaArtes();
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
$('foco').scrollIntoView({behavior:'smooth'});
});
// carrossel
(function(){
const tr=$('trilho'); if(!tr) return;
const slides=[...tr.children], pontos=$('cPontos');
slides.forEach((_,i)=>{
const b=document.createElement('button');
b.className='pt'+(i?'':' on'); b.setAttribute('aria-label','Cartão '+(i+1));
b.addEventListener('click',()=>ir(i)); pontos.appendChild(b);
});
function ir(i){
i=Math.max(0,Math.min(slides.length-1,i));
tr.scrollTo({left:slides[i].offsetLeft-tr.offsetLeft,behavior:'smooth'});
}
function atual(){
const meio=tr.scrollLeft+tr.clientWidth/2;
let a=0,d=Infinity;
slides.forEach((s,i)=>{
const c=s.offsetLeft-tr.offsetLeft+s.offsetWidth/2;
if(Math.abs(c-meio)<d){ d=Math.abs(c-meio); a=i; }
});
return a;
}
tr.addEventListener('scroll',()=>{
const a=atual();
[...pontos.children].forEach((p,i)=>p.classList.toggle('on',i===a));
},{passive:true});
$('cAnt').addEventListener('click',()=>ir(atual()-1));
$('cPro').addEventListener('click',()=>ir(atual()+1));
})();
// A declaração de formato só orienta: filtra o seletor e mostra o preço esperado.
// Se o arquivo que chegar contrariar, o arquivo ganha e o site avisa.
const AUTO=/[.](png|jpe?g|webp|pdf)$/i;
function pintaCaminhos(){
if(!$('caminhos')) return;
const ehF=modo&&ehFolha();
$('caminhos').style.display = ehF? '' : 'none';
if(!ehF) return;
document.querySelectorAll('#caminhos .cam').forEach(b=>b.classList.toggle('on', b.dataset.cam===caminho));
$('camA').textContent='a partir de '+rs(pisoEscada());
$('camB').textContent=rs(TABELA[modo])+' fixo';
$('inp').accept = caminho==='auto' ? '.png,.jpg,.jpeg,.pdf'
: '.tif,.tiff,.psd,.psb,.ai,.cdr';
$('zSub').textContent = caminho==='auto'
? 'PNG, JPG ou PDF · conferência automática, nota na tela'
: 'CDR, AI, PSD ou TIFF · sem conferência, metro pela tabela';
}
document.querySelectorAll('#caminhos .cam').forEach(b=>b.addEventListener('click',()=>{
caminho=b.dataset.cam; avisoCam(''); pintaCaminhos();
}));
function avisoCam(txt){
const el=$('trocouCam'); if(!el) return;
el.innerHTML=txt; el.style.display = txt? '' : 'none';
}
function pintaEntrega(){
document.querySelectorAll('.opE').forEach(b=>
b.classList.toggle('on', b.dataset.ent===entrega.tipo));
$('cep').classList.toggle('on', entrega.tipo==='frete');
$('vFrete').textContent = entrega.tipo!=='frete' ? '—'
: entrega.cotado ? rs(entrega.valor) : 'a cotar';
$('avisoE').innerHTML = entrega.tipo==='retira'
? 'DTF é impresso depois que você paga. Avisamos no WhatsApp quando estiver '+
'<b>pronto para retirar</b> — não venha antes do aviso.'
: '';
$('bPagar').disabled = !entrega.cotado || !clienteOk();
pintaPedido();
}
// máscaras e conferência dos campos do cliente
function liga(id,fmt,val,chave,erro,msg){
const el=$(id), er=$(erro);
el.addEventListener('input',e=>{
e.target.value=fmt(e.target.value);
cliente[chave]=e.target.value;
el.classList.remove('ruim','bom'); er.textContent='';
if(val(e.target.value)) el.classList.add('bom');
pintaEntrega();
});
el.addEventListener('blur',()=>{
if(!el.value){ el.classList.remove('ruim','bom'); er.textContent=''; return; }
const ok=val(el.value);
el.classList.toggle('ruim',!ok); el.classList.toggle('bom',ok);
er.textContent = ok? '' : msg;
});
}
liga('fCnpj', v=>{
const d=v.replace(/\D/g,'').slice(0,14);
return d.replace(/^(\d{2})(\d)/,'$1.$2').replace(/^(\d{2})\.(\d{3})(\d)/,'$1.$2.$3')
.replace(/\.(\d{3})(\d)/,'.$1/$2').replace(/(\d{4})(\d)/,'$1-$2');
}, cnpjOk, 'cnpj', 'eCnpj', 'CNPJ inválido · confira os números');
liga('fZap', v=>{
const d=v.replace(/\D/g,'').slice(0,11);
return d.length>6 ? '('+d.slice(0,2)+') '+d.slice(2,d.length-4)+'-'+d.slice(-4)
: d.length>2 ? '('+d.slice(0,2)+') '+d.slice(2) : d;
}, zapOk, 'zap', 'eZap', 'Faltam dígitos no número');
liga('fMail', v=>v.trim(), mailOk, 'mail', 'eMail', 'E-mail inválido');
function pintaZoom(){
$('zTxt').textContent=Math.round(ZOOMS[zoomI]*100)+'%';
$('zMenos').disabled = zoomI===0;
$('zMais').disabled = zoomI===ZOOMS.length-1;
}
$('zMais').addEventListener('click',()=>{
if(zoomI<ZOOMS.length-1){ zoomI++; pintaZoom(); previaAoVivo(); }
});
$('zMenos').addEventListener('click',()=>{
if(zoomI>0){ zoomI--; pintaZoom(); previaAoVivo(); }
});
pintaZoom();
document.querySelectorAll('.opE').forEach(b=>b.addEventListener('click',()=>{
entrega.tipo=b.dataset.ent;
entrega.cotado = entrega.tipo==='retira';
entrega.valor=0;
if(entrega.tipo==='frete') $('cepMsg').innerHTML='';
pintaEntrega();
}));
$('cepIn').addEventListener('input',e=>{
const v=e.target.value.replace(/\D/g,'').slice(0,8);
e.target.value = v.length>5 ? v.slice(0,5)+'-'+v.slice(5) : v;
entrega.cep=v; entrega.cotado=false; entrega.valor=0; pintaEntrega();
});
$('bCep').addEventListener('click',async()=>{
if(entrega.cep.length!==8){ $('cepMsg').innerHTML='CEP incompleto.'; return; }
if(window.dtfFreight){ await window.dtfFreight(); return; }
// A cotação vem da transportadora. Enquanto a integração não existir, o pedido não
// fecha por frete: o valor precisa estar fechado antes do Mercado Pago.
$('cepMsg').innerHTML='<b style="color:#FFB63D">Cotação de frete ainda não integrada.</b><br>'+
'O valor precisa vir da transportadora antes do pagamento — senão você pagaria um '+
'total que não é o final. Por enquanto, retire em Franca ou fale conosco no WhatsApp.';
pintaEntrega();
});
$('bMais').addEventListener('click',()=>{
if(itemAtual){ pedido.push(itemAtual); itemAtual=null; }
$('atual').style.display='none'; $('carrLin').innerHTML='';
$('foco').classList.remove('on'); $('cards').style.display='';
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
modo=null; artes=[]; folhas=[]; metros=0; nota=0; montagemCm=0;
pintaPedido();
document.getElementById('envio').scrollIntoView({behavior:'smooth'});
});
$('bPagar').addEventListener('click',()=>{
if(window.dtfCheckout) window.dtfCheckout();
else alert('O pedido online está indisponível no momento. Tente novamente em instantes.');
});
pintaEntrega();

View File

@@ -0,0 +1,95 @@
/* Site DTF — Choosing a product, and the declared sheet/loose-artwork switch.
Extracted verbatim from the single inline script in dtf-site.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── escolha
document.querySelectorAll('.ec').forEach(b=>b.addEventListener('click',()=>abrir(b.dataset.modo)));
function abrir(m){
modo=m; artes=[]; folhas=[]; metros=0; nota=0; reencaixado=false;
montagemCm=0;
avisoTipo('');
pintaModo();
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
recemChegada=null; caminho='auto'; avisoCam(''); pintaCaminhos();
$('vMt').textContent='—'; previaAoVivo(); agendaAvaliacao();
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
$('foco').scrollIntoView({behavior:'smooth',block:'start'});
}
function pintaModo(){
const m=modo;
const c=MODOS[m];
$('cards').style.display='none'; $('foco').classList.add('on');
$('fTit').textContent=c.tit; $('fLg').textContent=c.lg;
$('fPreco').textContent=c.preco; $('fSub').textContent=c.sub;
$('zTit').textContent=c.zt; $('zSub').textContent=c.zs;
$('inp').multiple=c.multi;
$('inp').accept = ehFolha(m) ? '.png,.jpg,.jpeg,.pdf'
: '.png,.jpg,.jpeg,.webp';
$('catFoco').className='cat '+(ehFolha(m)?'file':'av');
$('larguraFolhaPronta').textContent=String(c.larg).replace('.',',');
$('foco2').classList.remove('sofila'); // a caixa de requisitos vale nos 4 modos
$('montcabTit').textContent = ehFolha(m) ? 'Sua folha' : 'Montagem ao vivo';
pintaTipoEnvio();
}
// O par folha montada / artes separadas fica visível e com preço nos dois lados,
// nos dois modos: nenhuma troca acontece sem o cliente ver o que muda, e sempre
// dá para voltar atrás sem recomeçar o pedido.
function pintaTipoEnvio(){
const el=$('tipoEnvio'); if(!el) return;
el.hidden=!modo;
if(el.hidden) return;
const folha = ehFolha() ? modo : PAR[modo];
const avulsa = ehFolha() ? PAR[modo] : modo;
$('tipoFolhaPreco').textContent='a partir de '+rs(pisoEscada(folha));
$('tipoAvulsaPreco').textContent='a partir de '+rs(pisoEscada(avulsa))+' · montagem inclusa';
$('larguraFolhaPronta').textContent=String(MODOS[folha].larg).replace('.',',');
const atual = ehFolha() ? 'folha' : 'avulsa';
el.querySelectorAll('.cam').forEach(b=>{
b.classList.toggle('on', b.dataset.tipo===atual);
b.disabled=folhas.length>0 || artes.length>0; // já tem arquivo: a escolha está feita
});
}
function avisoTipo(txt){
const el=$('avisoTipo'); if(!el) return;
el.innerHTML=txt; el.style.display = txt? '' : 'none';
}
// A imagem foi declarada folha montada mas não tem resolução para isso. Devolve o
// arquivo com o motivo e o caminho certo — sem trocar preço por conta própria.
function recusaFolha(x, md){
const arquivo=x.f, destino = modo==='uvfile' ? 'uv' : 'avulsa';
folhas=folhas.filter(y=>y!==x);
pintaFolha();
avisoTipo('<b>'+escapeHTML(arquivo.name)+' não é uma folha montada.</b> '+
'Para ocupar os '+n1(larguraFilme())+' cm do filme ela teria só '+md.dpiFolha+
' DPI, e o mínimo para imprimir é '+DPI_RECUSA+'. '+
'Se for uma arte para montarmos, use <b>'+MODOS[destino].tit+'</b>, '+
'a partir de '+rs(pisoEscada(destino))+'/m. '+
'<button type="button" id="usarAvulsa">Enviar como '+MODOS[destino].tit.toLowerCase()+'</button>');
const botao=$('usarAvulsa');
if(botao) botao.addEventListener('click',()=>{
avisoTipo(''); trocaTipo(PAR[modo]); sel([arquivo]);
});
}
document.querySelectorAll('#tipoEnvio .cam').forEach(b=>b.addEventListener('click',()=>{
if(b.disabled) return;
const querFolha=b.dataset.tipo==='folha';
if(querFolha===ehFolha()) return;
avisoTipo(''); trocaTipo(PAR[modo]); // escolha explícita do cliente
}));
// Troca declarada, nunca silenciosa: o cliente já viu os dois preços no seletor.
function trocaTipo(destino){
if(!destino || modo===destino) return;
folhas=[]; artes=[]; recemChegada=null; metros=0; nota=0; montagemCm=0;
modo=destino;
$('lista').innerHTML=''; $('rA').style.display='none'; recusa([]);
caminho='auto'; avisoCam('');
pintaModo(); pintaCaminhos(); limpaPaineis(); agendaAvaliacao(); previaAoVivo();
}
$('bVoltar').addEventListener('click',()=>{
itemAtual=null; $('atual').style.display='none'; $('carrLin').innerHTML=''; pintaPedido();
$('foco').classList.remove('on'); $('cards').style.display='';
['qual','prev'].forEach(id=>$(id).classList.remove('on'));
if(!pedido.length) $('carr').classList.remove('on');
document.getElementById('envio').scrollIntoView({behavior:'smooth'});
});

View File

@@ -0,0 +1,353 @@
/* Site DTF — Masks, the nesting engine, and drawing the assembled film.
Extracted verbatim from the single inline script in dtf-site.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── imagens e prévia
function carregarImagem(file){
return new Promise(res=>{
if(!RENDERIZA.test(file.name)) return res(null);
const fr=new FileReader();
fr.onload=()=>{ const im=new Image(); im.onload=()=>res(im); im.onerror=()=>res(null); im.src=fr.result; };
fr.onerror=()=>res(null);
fr.readAsDataURL(file);
});
}
// A folha precisa parecer uma folha: borda, largura marcada e régua de metros.
// Sem isso o cliente não tem noção de escala e acha que está mal encaixado.
function molduraFolha(ctx,W,H,larguraCm,metrosReais){
const REG=34; // faixa da régua, à direita
ctx.save();
// fundo da régua
ctx.fillStyle='#FAFAF8'; ctx.fillRect(W,0,REG,H);
ctx.strokeStyle='#D5DAE3'; ctx.lineWidth=1;
ctx.beginPath(); ctx.moveTo(W+.5,0); ctx.lineTo(W+.5,H); ctx.stroke();
// marcas a cada 10 cm, número a cada metro
const pxPorM = H/Math.max(metrosReais,0.01);
ctx.font='9px "Roboto", sans-serif'; ctx.textAlign='left';
for(let cm=0; cm<=metrosReais*100; cm+=10){
const y=cm/100*pxPorM;
if(y>H) break;
const metro = cm%100===0;
ctx.strokeStyle = metro? '#B4BAC5' : '#E1E5EC';
ctx.beginPath(); ctx.moveTo(W+1,y+.5); ctx.lineTo(W+(metro?12:6),y+.5); ctx.stroke();
if(metro && cm>0){
ctx.fillStyle='#8A8F98';
ctx.fillText((cm/100)+' m', W+14, y+3);
}
}
// borda da folha
ctx.strokeStyle='#B4BAC5'; ctx.lineWidth=1;
ctx.strokeRect(.5,.5,W-1,H-1);
// cota da largura, no topo
ctx.fillStyle='#FFFFFFDD'; ctx.fillRect(0,0,W,18);
ctx.strokeStyle='#8A8F98'; ctx.lineWidth=1;
ctx.beginPath();
ctx.moveTo(8,9); ctx.lineTo(W-8,9);
ctx.moveTo(8,5); ctx.lineTo(8,13);
ctx.moveTo(W-8,5); ctx.lineTo(W-8,13);
ctx.stroke();
const t=String(larguraCm).replace('.',',')+' cm';
ctx.font='600 10px "Roboto", sans-serif'; ctx.textAlign='center';
const tw=ctx.measureText(t).width;
ctx.fillStyle='#FAFAF8'; ctx.fillRect(W/2-tw/2-6,2,tw+12,14);
ctx.fillStyle='#5A6570'; ctx.fillText(t,W/2,13);
ctx.restore();
}
const LOGO_URL='https://cdn.vnda.com.br/dropstar/2025/12/17/logo-header-15125700-16129080.png?v=1766009286';
const logoImg=new Image(); logoImg.crossOrigin='anonymous'; logoImg.src=LOGO_URL;
function marcaDagua(ctx,w,h){
ctx.save(); ctx.globalAlpha=.14;
const passo=Math.max(90,w/2.1);
ctx.translate(w/2,h/2); ctx.rotate(-Math.PI/7); ctx.translate(-w/2,-h/2);
if(logoImg.complete && logoImg.naturalWidth){
const lw=Math.max(70,w/3.4), lh=lw*(logoImg.naturalHeight/logoImg.naturalWidth);
for(let y=-h;y<h*2;y+=passo) for(let x=-w;x<w*2;x+=passo*1.7)
ctx.drawImage(logoImg,x,y,lw,lh);
}else{
ctx.fillStyle='#111';
ctx.font='700 '+Math.max(13,Math.round(w/13))+'px Inter, sans-serif'; ctx.textAlign='center';
for(let y=-h;y<h*2;y+=passo) for(let x=-w;x<w*2;x+=passo*1.5) ctx.fillText('PRÉVIA',x,y);
}
ctx.restore();
}
const CEL=GAP_CM; // célula da máscara, em cm = a folga de 5 mm
// Dilata 1 célula para a direita e para baixo. Como toda peça é dilatada igual,
// duas vizinhas ficam separadas por exatamente 1 célula — os 5 mm da regra.
function dilatar(bruto,cw,ch,cheias){
const cw2=cw+1, ch2=ch+1;
const out=new Uint8Array(cw2*ch2);
for(let y=0;y<ch;y++) for(let x=0;x<cw;x++){
if(!bruto[y*cw+x]) continue;
out[y*cw2+x]=1; out[y*cw2+x+1]=1; out[(y+1)*cw2+x]=1; out[(y+1)*cw2+x+1]=1;
}
return {cw:cw2,ch:ch2,bits:out,cheias}; // cheias = área de arte, sem a folga
}
function mascara(img,w,h,rot,esp=false){
const cw=Math.max(1,Math.ceil(w/CEL)), ch=Math.max(1,Math.ceil(h/CEL));
const cv=document.createElement('canvas');
cv.width=cw; cv.height=ch;
const ctx=cv.getContext('2d',{willReadFrequently:true});
let bruto, cheias;
if(!img){ // sem imagem: caixa cheia
bruto=new Uint8Array(cw*ch).fill(1); cheias=cw*ch;
return dilatar(bruto,cw,ch,cheias);
}
ctx.save();
ctx.translate(cw/2,ch/2);
ctx.rotate(rot*Math.PI/180);
if(esp) ctx.scale(-1,1);
const dw=rot%180===90?ch:cw, dh=rot%180===90?cw:ch;
ctx.drawImage(img,-dw/2,-dh/2,dw,dh);
ctx.restore();
const d=ctx.getImageData(0,0,cw,ch).data;
bruto=new Uint8Array(cw*ch); cheias=0;
for(let i=0,j=3;i<bruto.length;i++,j+=4){ bruto[i] = d[j]>16 ? 1 : 0; cheias+=bruto[i]; }
return dilatar(bruto,cw,ch,cheias);
}
// Encaixe pela forma, como o motor do servidor: mapa de ocupação real,
// máscara por arte e candidatos numa grade ao longo da largura.
// pecas em centímetros: {w,h,img}. W é a largura do filme em cm.
function encaixar(pecas,W){
const cols=Math.ceil(W/CEL)+1; // +1 = a coluna de folga da dilatação
let linhas=0; const mapa=[];
function garantir(ate){ while(linhas<ate){ mapa.push(new Uint8Array(cols)); linhas++; } }
function colide(m,cx,cy){
if(cx<0||cy<0||cx+m.cw>cols) return true;
for(let y=0;y<m.ch;y++){
const gy=cy+y;
if(gy>=linhas) continue; // vazio abaixo do que já existe
const L=mapa[gy], off=y*m.cw;
for(let x=0;x<m.cw;x++)
if(m.bits[off+x] && L[cx+x]) return true;
}
return false;
}
function ocupar(m,cx,cy){
garantir(cy+m.ch);
for(let y=0;y<m.ch;y++){
const L=mapa[cy+y], off=y*m.cw;
for(let x=0;x<m.cw;x++) if(m.bits[off+x]) L[cx+x]=1;
}
}
const postas=[];
const ordem=[...pecas].sort((a,b)=>(b.w*b.h)-(a.w*a.h));
ordem.forEach(p=>{
const oris=[{w:p.w,h:p.h,rot:0}];
if(!p.mPronta && !p.travado && Math.abs(p.w-p.h)>1 && p.h<=W) oris.push({w:p.h,h:p.w,rot:90});
let melhor=null;
oris.forEach(o=>{
if(o.w>W) return;
// a máscara segue a medida da orientação escolhida · girada, largura e altura trocam
const m = (p.mPronta && o.rot===0) ? p.mPronta : mascara(p.img, o.w, o.h,
o.rot+(p.a?.giro||0), p.a?.esp);
const xs=new Set([0]), ys=new Set([0]);
postas.forEach(q=>{ xs.add(q.cx+q.m.cw); xs.add(q.cx); ys.add(q.cy+q.m.ch); ys.add(q.cy); });
const passoX=Math.max(1,Math.round(cols/34));
for(let x=0;x+m.cw<=cols;x+=passoX) xs.add(x);
xs.add(cols-m.cw);
const passoY=Math.max(1,Math.round(Math.max(linhas,1)/50));
for(let y=0;y<=linhas;y+=passoY) ys.add(y);
const XS=[...xs].filter(x=>x>=0 && x+m.cw<=cols && x*CEL+o.w<=W+0.01).sort((a,b)=>a-b);
const YS=[...ys].filter(y=>y>=0).sort((a,b)=>a-b);
for(const cy of YS){
if(melhor && cy*CEL > melhor.y + CEL) break;
for(const cx of XS){
if(cx*CEL + o.w > W + 0.01) continue; // não deixa a peça sair do filme
if(colide(m,cx,cy)) continue;
const cand={cx,cy,m,x:cx*CEL,y:cy*CEL,w:o.w,h:o.h,rot:o.rot,ref:p};
// menor y, depois menor x · em empate, mantém a orientação original.
// Desempatar por largura gira tudo, e texto deitado na prévia parece erro.
const ganha = !melhor || cand.y<melhor.y ||
(cand.y===melhor.y && cand.x<melhor.x) ||
(cand.y===melhor.y && cand.x===melhor.x && cand.rot===0 && melhor.rot!==0);
if(ganha) melhor=cand;
break;
}
}
});
if(!melhor){
const k=Math.min(1, W/p.w); // cabe na largura, nem que seja reduzindo
const w=p.w*k, h=p.h*k;
const m=mascara(p.img,w,h,p.a?.giro||0,p.a?.esp);
melhor={cx:0,cy:linhas,m,x:0,y:linhas*CEL,w,h,rot:0,ref:p};
}
// a folga só existe entre peças, não é folha cobrada
ocupar(melhor.m,melhor.cx,melhor.cy);
postas.push(melhor);
});
const altura=Math.max(...postas.map(p=>p.y+p.h),1);
return {pos:postas, altura};
}
function desenhaMontagem(area,lista,mts,W,fora,aoTerminar){
const versao=area.montagemVersao=(area.montagemVersao||0)+1;
const filme=larguraFilme(), PX_CM=W/filme;
const itens=lista.map(a=>({...a}));
if(fora) fora.innerHTML='';
if(!itens.length){
area.innerHTML='<div class="semmont"><b>A montagem aparece aqui</b>'+
'<span>informe a largura de cada arte e a folha se desenha sozinha</span></div>';
if(aoTerminar) aoTerminar({alturaCm:0, uso:0, girou:0});
return;
}
Promise.all(itens.map(a=>carregarImagem(a.f).then(img=>({a,img})))).then(imagens=>{
if(area.montagemVersao!==versao) return;
const cargas=imagens.flatMap(c=>Array.from({length:c.a.q||1},()=>({...c})));
cargas.forEach(c=>{
c.w = c.a.cm; // o motor trabalha em centímetros
c.h = c.w * (c.img ? (c.a.giro? c.img.width/c.img.height : c.img.height/c.img.width) : 1);
c.travado = !!c.a.giro; // giro do cliente manda no encaixe
});
const enc=encaixar(cargas,filme);
const alturaCm=enc.altura;
const pos=enc.pos.map(p=>({...p, x:p.x*PX_CM, y:p.y*PX_CM, w:p.w*PX_CM, h:p.h*PX_CM}));
const altura=alturaCm*PX_CM;
const usado=Math.max(altura,10);
const REG=34;
const cv=document.createElement('canvas');
cv.width=W+REG; cv.height=Math.round(usado);
const ctx=cv.getContext('2d');
ctx.fillStyle='#fff'; ctx.fillRect(0,0,cv.width,cv.height);
const pal=['#C3CEDD','#B7A9C8','#A9B7C8','#A8BFA9','#C8C0A0','#CBB6B6'];
let girou=0;
pos.forEach((p,j)=>{
const c=p.ref;
if(p.rot===90) girou++;
ctx.save();
const giro = (p.rot===90?90:0) + (c.a && c.a.giro? 90:0);
ctx.translate(p.x+p.w/2, p.y+p.h/2);
ctx.rotate(giro*Math.PI/180);
if(c.a && c.a.esp) ctx.scale(-1,1);
const dw = giro%180===90 ? p.h : p.w, dh = giro%180===90 ? p.w : p.h;
if(c.img) ctx.drawImage(c.img,-dw/2,-dh/2,dw,dh);
else{ ctx.fillStyle=pal[j%pal.length]; ctx.fillRect(-dw/2,-dh/2,dw,dh); }
ctx.restore();
if(!c.img){
ctx.fillStyle='#5A6570'; ctx.font='11px "Roboto", sans-serif'; ctx.textAlign='center';
ctx.fillText(c.a.f.name.split('.').pop().toUpperCase(),p.x+p.w/2,p.y+p.h/2+4);
}
});
molduraFolha(ctx,W,usado,filme,alturaCm/100);
marcaDagua(ctx,W,cv.height);
area.innerHTML=''; area.appendChild(cv);
// O que virou arte é a área pintada, não a soma das caixas: com encaixe por forma
// as caixas se sobrepõem de propósito e a soma delas passa de 100%.
const areaArteCm=enc.pos.reduce((s,p)=>
s + (p.m && p.m.cheias!=null ? p.m.cheias*CEL*CEL : p.w*p.h), 0);
const uso=Math.max(1,Math.min(100,
Math.round(areaArteCm/(filme*Math.max(alturaCm,0.1))*100)));
const el=document.createElement('div');
el.className = uso>=70? 'usoBom' : 'usoRuim';
el.innerHTML = (uso>=70
? '<b>'+uso+'% da folha virou arte</b> bom aproveitamento'
: '<b>'+uso+'% da folha virou arte</b> dá para melhorar: artes de larguras parecidas '+
'se encaixam melhor lado a lado')
+ (girou? ' · <b style="display:inline">'+girou+
(girou>1?' artes giradas':' arte girada')+' 90°</b> para caber melhor':'');
(fora||area).appendChild(el);
if(aoTerminar) aoTerminar({alturaCm, uso, girou});
});
}
function previaArtes(){ desenhaMontagem($('pArea'), artes, metros, 560); }
// montagem ao vivo, ao lado da fila · redesenha 140 ms depois da ultima mexida
let tMont=null;
function previaAoVivo(){
clearTimeout(tMont);
$('vArea').montagemVersao=($('vArea').montagemVersao||0)+1;
if(ehFolha()){
$('vUso').innerHTML='';
const tot=folhaTotalM();
$('vMt').textContent = tot>0 ? fmtM(cobrar(tot))+' m' : '—';
const medidas=folhas.filter(x=>x.med);
const previews=folhas.filter(x=>x.previewSrc);
if(previews.length){
const painel=document.createElement('div'); painel.className='folhaPrevias';
previews.forEach((x,index)=>{
const figura=document.createElement('figure'); figura.className='folhaPrevia';
const imagem=new Image(); imagem.src=x.previewSrc; imagem.alt=x.f.name;
const legenda=document.createElement('figcaption');
legenda.textContent=(previews.length>1 ? (index+1)+'. ' : '')+x.f.name+
(x.med ? ' · '+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm' : '');
figura.append(imagem,legenda); painel.append(figura);
});
$('vArea').replaceChildren(painel);
}else{
$('vArea').innerHTML = medidas.length
? '<div class="semmont"><b>'+(medidas.length>1
? medidas.length+' folhas · '+fmtM(tot)+' m'
: n1(medidas[0].med.larg)+' × '+n1(medidas[0].med.alt)+' cm')+'</b>'+
'<span>'+(medidas.length>1? 'medidas lidas de cada arquivo' : medidas[0].med.fonte)+
' · vão para a máquina do jeito que você montou</span></div>'
: '<div class="semmont"><b>Sua folha aparece aqui</b>'+
'<span>a medida é lida do próprio arquivo, não do peso dele</span></div>';
}
return;
}
tMont=setTimeout(()=>{
const prontas=artes.filter(a=>a.cm>0);
const W=Math.round(300*ZOOMS[zoomI]);
$('vArea').classList.toggle('ampliado', zoomI>0);
desenhaMontagem($('vArea'), prontas, 0, W, $('vUso'), r=>{
montagemCm = r.alturaCm; // altura real da folha montada
$('vMt').textContent = r.alturaCm? fmtM(cobrar(r.alturaCm/100))+' m' : '—';
resumoArtes(); // o resumo lê a mesma folha
agendaAvaliacao(); // a nota só sai com a metragem pronta
});
$('vArea').scrollTop=0;
},140);
}
function motivoNota(){
if(!nota) return 'sem nota ainda · a conferência roda na sala antes de imprimir';
if(!ehFolha()){
const ruins=artes.filter(a=>a.cm>0 && (a.px/(a.cm/2.54))<300);
if(!ruins.length) return 'nota '+nota+' · todas as artes em 300 DPI ou mais';
const pior=ruins.reduce((p,a)=>(a.px/(a.cm/2.54))<(p.px/(p.cm/2.54))?a:p);
return 'nota '+nota+' · '+ruins.length+(ruins.length>1?' artes abaixo':' arte abaixo')+
' de 300 DPI · a menor é "'+pior.f.name+'" com '+
Math.round(pior.px/(pior.cm/2.54))+' DPI em '+pior.cm.toFixed(1).replace('.',',')+' cm';
}
return nota>=90 ? 'nota '+nota+' · resolução ótima em toda a folha'
: 'nota '+nota+' · a resolução é o único ponto — o resto corrigimos por você';
}
function previa(){
// Na folha pronta o painel não tem o que mostrar: a folha vem fechada, ninguém
// olhou dentro dela. Largura e metragem — o que de fato medimos — já aparecem
// na coluna ao lado e no carrinho.
if(ehFolha()){ $('prev').classList.remove('on'); return; }
const cob=cobrar(metros), sobra=+(cob-metros).toFixed(2);
$('pMt').innerHTML=fmtM(cob)+'<small> m</small>';
$('pSub').textContent='folha montada por nós com as artes que você enviou';
const linhas=[
['var(--verde)','✓','Largura',larguraFilme()+' cm · dentro do padrão da máquina'],
['var(--verde)','✓','Espaço entre artes','5 mm garantidos'],
[nota>=75?'var(--verde)':'var(--laranja)', nota>=75?'✓':'!','Qualidade da folha',motivoNota()],
['var(--verde)','✓','Metragem proporcional',
'sua folha tem '+fmtM(metros)+' m e você paga '+fmtM(cob)+
' m · arredondamos só até os 10 cm mais próximos'],
];
linhas.push(['var(--verde)','+','Identificação do pedido',
'aplicamos seu nome e o número do pedido no fim da folha · por nossa conta']);
$('pInfo').innerHTML=linhas.map(l=>
'<div class="li"><span class="c" style="background:'+l[0]+
(l[0]==='var(--laranja)'?';color:#241703':'')+'">'+l[1]+'</span>'+
'<div><b>'+escapeHTML(l[2])+'</b><span>'+escapeHTML(l[3])+'</span></div></div>').join('');
$('prev').classList.add('on');
$('pArea').innerHTML='<div class="carregando">montando a prévia…</div>';
try{ previaArtes(); }
catch(e){ $('pArea').innerHTML='<div class="semprev"><b>Prévia indisponível</b>'+
'O arquivo será processado normalmente.</div>'; }
}

400
local/static/site-pdf.js Normal file
View File

@@ -0,0 +1,400 @@
/* Site DTF — PDF measurement and rasterisation, and the DPI of images inside one.
Extracted verbatim from the single inline script in dtf-site.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── PDF também é conferido: rasteriza a página e mede o DPI das imagens de dentro.
// Arte vetorial não tem resolução — nesses casos a nota é máxima, e isso é correto.
// Served from this origin, not a CDN: the Site keeps working when a third party
// does not, and the CSP can name only 'self' for scripts and workers. Provenance
// and hashes are recorded in local/static/vendor/README.md.
const PDFJS_URL='/vendor/pdf.min.js';
const PDFJS_WORKER=new URL('/vendor/pdf.worker.min.js', location.origin).href;
let pdfLibP=null, temWorker=null;
// Alguns navegadores e iframes bloqueiam Worker de blob. Descobrimos antes de
// tentar, porque sem worker o PDF é lido na thread principal e precisa de outra
// estratégia: grade mais grossa e mais tempo.
function testaWorker(url){
try{ const w=new Worker(url); w.terminate(); return true; }catch(e){ return false; }
}
function carregarPdfJs(){
if(typeof window==='undefined') return Promise.resolve(null);
if(window.pdfjsLib) return Promise.resolve(window.pdfjsLib);
if(pdfLibP) return pdfLibP;
pdfLibP=new Promise(res=>{
const sc=document.createElement('script');
sc.src=PDFJS_URL;
sc.onload=()=>{
try{
const codigo='importScripts("'+PDFJS_WORKER+'");';
const url=URL.createObjectURL(new Blob([codigo],{type:'application/javascript'}));
temWorker=testaWorker(url);
window.pdfjsLib.GlobalWorkerOptions.workerSrc = temWorker? url : PDFJS_WORKER;
}catch(e){
temWorker=false;
try{ window.pdfjsLib.GlobalWorkerOptions.workerSrc=PDFJS_WORKER; }catch(e2){}
}
res(window.pdfjsLib||null);
};
sc.onerror=()=>res(null);
document.head.appendChild(sc);
});
return pdfLibP;
}
// DPI real de cada imagem embutida: acompanha a matriz de transformação e compara
// o tamanho em que a imagem foi colocada com quantos pixels ela tem.
function dpiDasImagens(page, ops){
const mul=(a,b)=>[a[0]*b[0]+a[2]*b[1], a[1]*b[0]+a[3]*b[1],
a[0]*b[2]+a[2]*b[3], a[1]*b[2]+a[3]*b[3],
a[0]*b[4]+a[2]*b[5]+a[4], a[1]*b[4]+a[3]*b[5]+a[5]];
const O=window.pdfjsLib.OPS;
let ctm=[1,0,0,1,0,0]; const pilha=[]; const dpis=[];
for(let i=0;i<ops.fnArray.length;i++){
const fn=ops.fnArray[i], ar=ops.argsArray[i];
if(fn===O.save) pilha.push(ctm.slice());
else if(fn===O.restore) ctm=pilha.pop()||[1,0,0,1,0,0];
else if(fn===O.transform) ctm=mul(ctm,ar);
else if(fn===O.paintImageXObject||fn===O.paintJpegXObject||fn===O.paintImageXObjectRepeat){
let im=null;
try{ im=page.objs.get(ar[0]); }catch(e){}
if(!im||!im.width) continue;
const larguraPt=Math.hypot(ctm[0],ctm[1]);
if(larguraPt<1) continue;
const areaPt=Math.abs(ctm[0]*ctm[3]-ctm[1]*ctm[2]);
dpis.push({dpi: im.width/(larguraPt/72), area: areaPt});
}
}
return dpis;
}
// A régua de 96 DPI vale por arte, não pela folha inteira. Numa folha de 250 peças,
// um selo pequeno em 65 DPI não pode barrar o pedido: o que decide é quanto da
// área impressa está abaixo do limite.
function resumoDpi(lista){
if(!lista.length) return null;
const total=lista.reduce((t,x)=>t+x.area,0) || 1;
const abaixoPiso=lista.filter(x=>x.dpi<DPI_RECUSA);
const abaixoAviso=lista.filter(x=>x.dpi<DPI_AVISA);
const pond=lista.reduce((t,x)=>t+x.dpi*x.area,0)/total;
return {
pior: Math.round(Math.min(...lista.map(x=>x.dpi))),
ponderado: Math.round(pond),
fracBaixa: abaixoPiso.reduce((t,x)=>t+x.area,0)/total,
fracAviso: abaixoAviso.reduce((t,x)=>t+x.area,0)/total,
quantasBaixas: abaixoPiso.length,
quantasAviso: abaixoAviso.length,
total: lista.length
};
}
// Rasteriza a primeira página a 1 mm por pixel e devolve a tela para a análise
function rasterizarPdf(file, larguraCm, alturaCm){
let motivo=null;
const trabalho=carregarPdfJs().then(lib=>{
if(!lib){ motivo='não foi possível carregar o leitor de PDF'; return null; }
// sem worker tudo roda na thread principal · grade mais grossa para caber
const folga = temWorker===false ? 2.2 : 1;
return file.arrayBuffer().then(buf=>lib.getDocument({
data:buf, disableFontFace:true, isEvalSupported:false, useSystemFonts:false,
verbosity:0 // sem worker é fallback, não erro
}).promise).then(doc=>
doc.getPage(1).then(page=>{
const vp1=page.getViewport({scale:1});
const passo=passoDe(larguraCm, alturaCm||larguraCm)*folga;
const alvo=Math.max(8, Math.round(larguraCm/passo));
const esc=alvo/vp1.width;
const vp=page.getViewport({scale:esc});
const cv=document.createElement('canvas');
cv.width=Math.max(8,Math.round(vp.width)); cv.height=Math.max(8,Math.round(vp.height));
const ctx=cv.getContext('2d',{willReadFrequently:true});
return page.render({canvasContext:ctx, viewport:vp, background:'rgba(0,0,0,0)'})
.promise.then(()=>page.getOperatorList()).then(ops=>{
let dpis=[];
try{ dpis=dpiDasImagens(page,ops); }catch(e){}
const r=resumoDpi(dpis);
return {tela:cv, dpi: r? r.ponderado : null, res:r};
});
})
);
}).catch(e=>{ motivo=(e&&e.message)? e.message : 'erro ao abrir o PDF'; return null; });
// sem worker o processo é lento: damos mais tempo antes de desistir
const espera = temWorker===false ? 90000 : 30000;
const tarde=new Promise(r=>setTimeout(()=>{ motivo='demorou demais neste navegador'; r('tempo'); }, espera));
return Promise.race([trabalho, tarde]).then(r=>{
const saida = r==='tempo'? null : r;
if(!saida) return {erro: motivo||'não deu para conferir', semWorker: temWorker===false};
return saida;
});
}
function medirFolha(file){
return new Promise(res=>{
const nome=(file.name||'').toLowerCase();
if(RENDERIZA.test(nome)){
carregarImagem(file).then(img=>{
if(!img || !img.width) return res(null);
const L=larguraFilme();
// Uma imagem só é folha montada se tiver pixel para ocupar a largura do
// filme. Sem esta conferência, qualquer arte pequena vira "folha" e leva
// o preço por metro sem nunca ter sido montada.
res({larg:L, alt:+(img.height/img.width*L).toFixed(1), fonte:'proporção da imagem',
dpiFolha:Math.round(img.width/(L/2.54)), px:img.width});
});
return;
}
if(/\.pdf$/.test(nome)){
const pedaco=(inicio,fim)=>new Promise(r=>{
const fr=new FileReader();
fr.onload=()=>r(new TextDecoder('latin1').decode(new Uint8Array(fr.result)));
fr.onerror=()=>r('');
fr.readAsArrayBuffer(file.slice(inicio,fim));
});
const M=4*1048576;
Promise.all([pedaco(0,M), pedaco(Math.max(0,file.size-M), file.size)]).then(([a,b])=>{
const txt=a+b;
const re=/\/MediaBox\s*\[\s*(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)\s+(-?[\d.]+)/;
const m=txt.match(re);
if(!m) return res(null);
// UserUnit escala a página inteira · sem ele, 1 unidade = 1/72 pol
const uu=txt.match(/\/UserUnit\s+([\d.]+)/);
const esc=uu? Math.max(1,Math.min(75000,+uu[1])) : 1;
const ptW=Math.abs(+m[3]-+m[1]), ptH=Math.abs(+m[4]-+m[2]);
if(!(ptW>0&&ptH>0)) return res(null);
const w=ptW*PT_CM*esc, h=ptH*PT_CM*esc;
// acima de 14.400 unidades sem UserUnit o arquivo está fora da especificação
const foraDoPadrao = !uu && (ptW>PDF_MAX_PT || ptH>PDF_MAX_PT);
res({larg:+w.toFixed(1), alt:+h.toFixed(1),
fonte:'página do PDF'+(uu? ' · UserUnit '+esc : ''), foraDoPadrao});
});
return;
}
res(null);
});
}
function pintaFolha(){
const L=larguraFilme();
pintaTipoEnvio(); // trava o seletor enquanto houver folha
if(!folhas.length){ $('lista').innerHTML=''; agendaAvaliacao(); previaAoVivo(); return; }
$('lista').innerHTML = folhas.map((x,i)=>{
const lido=!!x.med, larga=lido && x.med.larg>L+0.5, suspeito=lido && x.med.foraDoPadrao;
const sub=(x.m||0)*(x.rep||1);
let medida='';
if(lido){
const a=x.an;
const conferido = a
? '<div class="conf"><b>Conferido de verdade</b>'+
'<span>'+a.artes+' peça'+(a.artes===1?'':'s')+' · '+a.aproveitamento+
'% da folha virou arte · '+
(a.residuos? a.residuos+' resíduo'+(a.residuos>1?'s':'') : 'sem resíduo')+' · '+
(a.fundoChapado? 'fundo chapado' : 'fundo transparente')+'</span>'+
'<span>resolução '+a.dpi+' DPI · '+
(a.fonteDpi==='imagens'? 'medida imagem por imagem dentro do PDF'
: a.fonteDpi==='vetor'? 'arte vetorial, sem imagem embutida'
: 'lida do arquivo')+
' · grade de '+n1(a.grade)+' mm</span></div>'
: '';
medida='<div class="med'+(larga||suspeito?' alerta':'')+'">'+
'<b>'+n1(x.med.larg)+' × '+n1(x.med.alt)+' cm</b>'+
'<span>'+x.med.fonte+' · '+fmtM(x.m)+' m de folha</span>'+
conferido+
(suspeito? '<em><b>Este PDF passa do tamanho que o formato comporta.</b> '+
'A especificação para em 508 cm e o seu tem '+n1(x.med.alt)+' cm. '+
'Ele abre certo só em quem o gerou — aqui vai precisar de tratamento. '+
'Manda em <b>PNG ou TIFF</b>, ou divide em partes de até 5 m, que sai na hora.</em>':'')+
(larga? '<em><b>Esta folha não roda neste filme.</b> Ela tem '+n1(x.med.larg)+
' cm de largura e o filme aqui tem '+n1(L)+' cm. Não dá para reduzir: '+
'encolher a folha encolheria cada arte junto.'+
(x.med.larg<=57.5
? ' Ela cabe no <b>DTF têxtil de 57 cm</b> — troque de produto ali em cima.'
: ' Remonte a folha em '+n1(L)+' cm e suba de novo.')+'</em>':'')+
'</div>';
}else{
const teto=TABELA[modo], piso=pisoEscada();
const dif=(x.m>0)? (teto-piso)*cobrar(x.m*(x.rep||1)) : 0;
medida='<div class="med pede">'+
'<b>'+escapeHTML(extDe(x.f.name))+' não abre no navegador</b>'+
'<span>Sem conseguir conferir o arquivo, não temos como dar nota — e sem nota '+
'o metro sai pela tabela, <b>'+rs(teto)+'</b>. Alguém vai abrir esse arquivo na mão '+
'antes de imprimir.</span>'+
'<div class="troca">Exportando a mesma folha em <b>PNG ou PDF</b>, a conferência é '+
'automática e o metro pode cair até <b>'+rs(piso)+'</b>'+
(dif>0.5? ' — <b>'+rs(dif)+' a menos</b> só nesta folha' : '')+'.</div>'+
'<span style="margin-top:9px">Informe o comprimento para seguir:</span>'+
'<input type="number" min="0.5" max="60" step="0.01" data-comp="'+i+'" '+
'placeholder="metros" value="'+(x.m? x.m.toFixed(2):'')+'">'+
'</div>';
}
const barra = x.pct!=null
? '<div class="prog"><i style="width:'+x.pct+'%"></i></div>'+
'<span class="progT">'+(x.pct<50?'lendo o arquivo…':
x.pct<70?'medindo a folha…':'conferindo a arte…')+'</span>'
: x.semAnalise
? '<div class="progF"><b>Não conseguimos conferir esta folha.</b> '+x.semAnalise+
'. Seguimos com o preço de tabela — se quiser a nota e o desconto, exporte a mesma '+
'folha em <b>PNG</b>, que a conferência é bem mais leve.</div>'
: '';
return '<div class="rep"><div class="l1"><b>'+
(folhas.length>1? (i+1)+'. ':'')+escapeHTML(x.f.name)+'</b>'+
'<span style="color:var(--fraco);font-size:10.5px">'+fmt(x.f.size)+'</span>'+
'<button data-rmf="'+i+'">×</button></div>'+
barra+medida+
(x.m>0 ?
'<div class="cps" style="margin-top:10px">'+
'<div><label>quantas vezes repetir</label>'+
'<input type="number" min="1" max="200" data-repf="'+i+'" value="'+x.rep+'"></div>'+
'<div class="conta"><b>'+fmtM(sub)+' m</b>'+
(x.rep>1? ' · '+x.rep+' × '+fmtM(x.m)+' m':'')+'</div>'+
'</div>' : '')+
'</div>';
}).join('');
// total do pedido, quando há mais de uma folha
const tot=folhaTotalM(), partes=Math.ceil(tot/20);
if(folhas.length>1 && tot>0){
$('lista').innerHTML += '<div class="somaF">'+
'<span>'+folhas.length+' folhas neste pedido</span>'+
'<b>'+fmtM(tot)+' m no total</b>'+
(partes>1? '<em>vai em '+partes+' partes de até 20 m</em>':'')+'</div>';
}else if(partes>1 && tot>0){
$('lista').innerHTML += '<div class="somaF"><span>folha longa</span>'+
'<b>'+fmtM(tot)+' m</b><em>vai em '+partes+' partes de até 20 m</em></div>';
}
$('lista').querySelectorAll('[data-rmf]').forEach(b=>b.addEventListener('click',()=>{
folhas.splice(+b.dataset.rmf,1); pintaFolha();
}));
$('lista').querySelectorAll('[data-repf]').forEach(el=>el.addEventListener('change',e=>{
folhas[+el.dataset.repf].rep=Math.max(1,Math.min(200,+e.target.value||1)); pintaFolha();
}));
$('lista').querySelectorAll('[data-comp]').forEach(el=>el.addEventListener('change',e=>{
folhas[+el.dataset.comp].m=Math.max(0,Math.min(60,+e.target.value||0)); pintaFolha();
}));
agendaAvaliacao(); previaAoVivo();
}
// sugestão de largura
const cabemNaLinha=w=>Math.max(1,Math.floor((larguraFilme()+GAP_CM)/(w+GAP_CM)));
const larguraPara=n=>(larguraFilme()-(n-1)*GAP_CM)/n;
// Só sugere se a folha realmente encolher. Caber mais um por fileira não adianta
// nada se a quantidade pedida continuar ocupando o mesmo número de fileiras.
function sugestao(a){
const w=a.cm, q=Math.max(1,a.q||1), prop=propDe(a);
const n=cabemNaLinha(w), alvo=Math.floor(larguraPara(n+1)*10)/10;
if(!(alvo>0) || alvo>=w) return null;
if((w-alvo)/w>0.12) return null; // não corta mais que 12% da peça
const fa=Math.ceil(q/n), fd=Math.ceil(q/(n+1));
if(fd>=fa) return null; // mesmo número de fileiras: não muda nada
const economiaCm=fa*w*prop - fd*alvo*prop;
if(economiaCm<0.5) return null; // menos de meio centímetro não é notícia
return {alvo, de:n, para:n+1, fileiras:fa-fd, cm:economiaCm};
}
// A cota em escala: o cliente ve a peca dentro da largura do filme, quantas cabem
// lado a lado e quanto sobra. Numero em texto ninguem le; retangulo todo mundo entende.
function vizPeca(a){
const BASE=300, L=larguraFilme();
const e0=BASE/L, alt0=a.cm*propDe(a)*e0;
const k = alt0>132 ? 132/alt0 : 1; // tira nunca fica alta demais
const e = e0*k;
const n=cabemNaLinha(a.cm), pw=a.cm*e, ph=a.cm*propDe(a)*e, gp=GAP_CM*e;
const sobra=L-(n*a.cm+(n-1)*GAP_CM);
const ext=escapeHTML(a.f.name.split('.').pop().toUpperCase());
let pecas='';
for(let i=0;i<n;i++) pecas+='<div class="pc" style="width:'+pw.toFixed(1)+'px;height:'+
ph.toFixed(1)+'px">'+(a.src?'<img src="'+a.src+'" alt="" style="transform:'+
(a.giro?'rotate(90deg) ':'')+(a.esp?'scaleX(-1)':'')+'">':'<span>'+ext+'</span>')+'</div>';
const Wt=(L*e+6).toFixed(1); // +6 = borda e respiro da tira
return '<div class="viz">'+
'<div class="vizRow">'+
'<div class="vizH" style="height:'+ph.toFixed(1)+'px"><span>'+n1(a.cm*propDe(a))+' cm</span></div>'+
'<div class="vizTira" style="width:'+Wt+'px">'+
'<div class="vizL"><i></i><b>'+n1(L)+' cm de filme</b><i></i></div>'+
'<div class="tira" style="height:'+ph.toFixed(1)+'px;gap:'+gp.toFixed(1)+'px">'+pecas+
(sobra>0.25?'<div class="folga"><span>sobra '+n1(sobra)+' cm</span></div>':'')+
'</div>'+
'</div>'+
'</div>'+
'<div class="vizPe"><b>'+n1(a.cm)+' cm</b> de largura · <b>'+n+
(n>1?' peças':' peça')+'</b> por fileira'+
(sobra>0.25?' · sobram <b>'+n1(sobra)+' cm</b>':' · largura toda aproveitada')+'</div>'+
'</div>';
}
const n1=v=>v.toFixed(1).replace('.',',');
function pintaArtes(){
$('lista').innerHTML=artes.map((a,i)=>{
let barra='', dica='', calc='<span>informe a largura para ver a qualidade</span>';
if(a.cm>0){
const dpi=Math.round(a.px/(a.cm/2.54)), n=Math.max(6,Math.min(100,Math.round(dpi/300*100)));
barra='<div class="qb2"><i class="'+cls(n)+'" style="width:'+n+'%"></i></div>';
calc='<span class="'+cls(n)+'"><b>'+n+'%</b> · '+dpi+' DPI</span><span>'+
a.q+' × '+n1(a.cm)+' × '+n1(a.cm*propDe(a))+' cm</span>';
const sug=sugestao(a);
dica = sug
? '<div class="dica"><b>Reduzindo para '+n1(sug.alvo)+' cm você economiza '+
n1(sug.cm)+' cm de folha</b>Passam de '+sug.de+' para '+sug.para+
' por fileira, e as suas '+a.q+' peças ocupam '+sug.fileiras+
' fileira'+(sug.fileiras>1?'s':'')+' a menos.<button data-usar="'+i+'" data-cm="'+
sug.alvo+'">Usar sugerido</button></div>'
: '';
dica = vizPeca(a) + dica;
}
return '<div class="art'+(a===recemChegada?' nova':'')+'"><div class="l1"><b>'+escapeHTML(a.f.name)+'</b>'+
'<span style="color:var(--fraco);font-size:10.5px">'+a.px+' px</span>'+
'<button data-rm="'+i+'">×</button></div><div class="cps">'+
'<div><label>largura na peça</label><input type="number" min="1" max="'+larguraFilme()+
'" placeholder="cm" data-cm="'+i+'" value="'+(a.cm||'')+'"></div>'+
'<div><label>quantas vezes</label><input type="number" min="1" placeholder="1" data-q="'+i+
'" value="'+a.q+'"></div></div>'+
'<div class="atalhos">'+
(TAMANHOS[larguraFilme()]||[]).map(t=>'<button class="tm'+
(Math.abs(a.cm-t)<0.05?' on':'')+'" data-tam="'+i+'" data-t="'+t+'">'+
n1(t)+' cm</button>').join('')+
'<span class="sep"></span>'+
'<button class="gr'+(a.giro?' on':'')+'" data-giro="'+i+'" title="Girar 90°">⟳</button>'+
'<button class="gr'+(a.esp?' on':'')+'" data-esp="'+i+'" title="Espelhar">⇋</button>'+
'</div>'+barra+'<div class="calc">'+calc+'</div>'+dica+'</div>';
}).join('');
$('lista').querySelectorAll('[data-rm]').forEach(b=>b.addEventListener('click',()=>{artes.splice(+b.dataset.rm,1);pintaArtes();}));
$('lista').querySelectorAll('[data-cm]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.cm].cm=+i.value||0;pintaArtes();}));
$('lista').querySelectorAll('[data-q]').forEach(i=>i.addEventListener('change',()=>{artes[+i.dataset.q].q=Math.max(1,Math.floor(+i.value||1));pintaArtes();}));
$('lista').querySelectorAll('[data-cm], [data-q]').forEach(i=>i.addEventListener('input',()=>{
const largura=i.hasAttribute('data-cm');
const a=artes[+(largura?i.dataset.cm:i.dataset.q)];
a[largura?'cm':'q']=largura?Math.max(0,+i.value||0):Math.max(1,Math.floor(+i.value||1));
previaAoVivo();
}));
$('lista').querySelectorAll('[data-usar]').forEach(b=>b.addEventListener('click',()=>{
artes[+b.dataset.usar].cm=+b.dataset.cm; pintaArtes();
}));
$('lista').querySelectorAll('[data-tam]').forEach(b=>b.addEventListener('click',()=>{
artes[+b.dataset.tam].cm=+b.dataset.t; pintaArtes();
}));
$('lista').querySelectorAll('[data-giro]').forEach(b=>b.addEventListener('click',()=>{
const a=artes[+b.dataset.giro]; a.giro=a.giro?0:90; pintaArtes();
}));
$('lista').querySelectorAll('[data-esp]').forEach(b=>b.addEventListener('click',()=>{
const a=artes[+b.dataset.esp]; a.esp=!a.esp; pintaArtes();
}));
resumoArtes();
previaAoVivo();
}
function resumoArtes(){
const r=$('rA'), prontas=artes.filter(a=>a.cm>0);
if(!prontas.length){ r.style.display='none'; return; }
const m=montagemCm/100; // a mesma folha que aparece ao lado
const notas=prontas.map(a=>Math.max(6,Math.min(100,Math.round((a.px/(a.cm/2.54))/300*100))));
const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
const falta=artes.length-prontas.length;
r.style.display='block'; r.className='resumoA on';
r.innerHTML='<div class="r"><span>artes com tamanho</span><b>'+prontas.length+' de '+artes.length+'</b></div>'+
'<div class="r"><span>qualidade média</span><b style="color:'+
(media>=85?'var(--verde)':media>=50?'var(--laranja)':'var(--vermelho)')+'">'+media+'%</b></div>'+
(pior<media?'<div class="r"><span>a pior arte</span><b>'+pior+'%</b></div>':'')+
(m>0?'<div class="r"><span>folha montada</span><b>'+fmtM(m)+' m</b></div>'+
(cobrar(m)>m+0.001?'<div class="r"><span>cobrança</span><b>'+fmtM(cobrar(m))+
' m'+(cobrar(m)===MINIMO_M?' · mínimo':'')+'</b></div>':''):'')+
(falta?'<div class="r" style="color:var(--laranja)"><span>faltam informar</span><b>'+falta+'</b></div>':'');
}

View File

@@ -0,0 +1,186 @@
/* Site DTF — The grade shown to the customer, and the reservations attached to it.
Extracted verbatim from the single inline script in dtf-site.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── enviar
// Avalia sozinho conforme o cliente sobe e preenche. Devolve o que ainda falta,
// para a fila dizer em texto em vez de travar num botão.
function avaliar(){
if(ehFolha()){
if(!folhas.length) return {pronto:false, falta:'Arraste sua folha montada para começar.'};
const larga=folhas.find(x=>x.med && x.med.larg > larguraFilme()+0.5);
if(larga) return {pronto:false, falta:'A folha '+larga.f.name+' tem '+n1(larga.med.larg)+
' cm e não cabe no filme de '+n1(larguraFilme())+' cm.'};
const semMedida=folhas.filter(x=>!(x.m>0)).length;
if(semMedida) return {pronto:false, falta:'Informe o comprimento de '+semMedida+
' folha'+(semMedida>1?'s':'')+'.'};
metros=folhaTotalM();
reencaixado=false;
const ans=folhas.map(x=>x.an).filter(Boolean);
if(!ans.length){
// sem análise possível (PDF, CDR, PSD): não inventa nota
const exts=[...new Set(folhas.map(f=>extDe(f.f.name)))].join(', ');
mostraNota([
['av','Sem nota · '+exts+' não abre no navegador',
'não temos como conferir a arte antes de imprimir'],
['er','Metro pela tabela · '+rs(TABELA[modo]),
'exportando em PNG ou PDF, cai até '+rs(pisoEscada())],
['fix','Alguém abre seu arquivo','a conferência é feita na mão, na sala']
], 0, folhas.length>1? folhas.length+' folhas' : folhas[0].f.name,
larguraFilme()+' cm · '+fmtM(metros)+' m · preço de tabela');
return {pronto:true};
}
const dpi=Math.min(...ans.map(a=>a.dpi));
const n=Math.max(6,Math.min(100,Math.round(dpi/DPI_AVISA*100)));
const soma=(k)=>ans.reduce((t,a)=>t+a[k],0);
const itens=[
ans.every(a=>a.vetor)
? ['ok','Arte vetorial','sem imagem embutida · imprime em qualquer tamanho']
: [dpi>=DPI_AVISA?'ok':dpi>=DPI_RECUSA?'av':'er','Resolução '+dpi+' DPI',
ans.some(a=>a.fonteDpi==='imagens')
? 'média por área das imagens dentro do PDF · pior em '+
Math.min(...ans.map(a=>a.res? a.res.pior : a.dpi))+' DPI'
: 'resolução do arquivo · arte ampliada antes de exportar não aparece aqui'],
['ok', soma('artes')+' peças na folha',
Math.round(soma('aproveitamento')/ans.length)+'% da folha virou arte'],
soma('residuos') ? ['er','Resíduo de recorte',
soma('residuos')+' ponto'+(soma('residuos')>1?'s':'')+' abaixo de 2 mm · a impressora imprime']
: ['ok','Sem resíduo de recorte','nada solto na folha'],
soma('encostadas') ? ['fix', soma('encostadas')+' peças a menos de 5 mm',
'se forem artes diferentes, separamos com 5 mm sem custo']
: ['ok','Espaço entre peças','nenhuma abaixo de 6 mm'],
ans.some(a=>a.fundoChapado) ? ['er','Fundo chapado','a folha está sem transparência']
: ['ok','Fundo transparente','canal alfa conferido'],
null
].filter(Boolean);
mostraNota(itens, n, folhas.length>1? folhas.length+' folhas' : folhas[0].f.name,
larguraFilme()+' cm · '+fmtM(metros)+' m');
return {pronto:true};
}
if(!artes.length) return {pronto:false, falta:'Arraste suas artes para começar.'};
const sem=artes.filter(a=>!a.cm).length;
if(sem) return {pronto:false, falta:'Informe a largura de '+sem+' arte'+(sem>1?'s':'')+
' para ver a nota e o preço.'};
// metragem = altura da folha depois de montada, com os 5 mm entre peças
metros=+(montagemCm/100).toFixed(4);
if(!(metros>0)) return {pronto:false, falta:'Montando a folha…'};
const notas=artes.map(a=>Math.max(6,Math.min(100,Math.round((a.px/(a.cm/2.54))/300*100))));
const media=Math.round(notas.reduce((s,n)=>s+n,0)/notas.length), pior=Math.min(...notas);
mostraNota([
['ok','Montagem por nossa conta','5 mm garantidos'],
[cls(pior),'Pior arte em '+pior+'%',pior>=85?'todas em boa resolução':'reenviar maior sai mais barato']
],media,artes.length+' arte'+(artes.length>1?'s':''),
'folha montada por nós · '+fmtM(metros)+' m');
return {pronto:true};
}
// redesenha 220 ms depois da última mexida, para não avaliar a cada tecla
let tAval=null;
function agendaAvaliacao(){
clearTimeout(tAval);
tAval=setTimeout(()=>{
const r=avaliar();
const el=$('aoVivo');
if(!el) return;
if(r.pronto){
el.className='aoVivo pronto';
el.innerHTML='<span class="pt2"></span><span><b>Nota e preço calculados.</b> '+
'Confira abaixo e feche o pedido.</span>';
}else{
el.className='aoVivo';
el.innerHTML='<span class="pt2"></span><span>'+escapeHTML(r.falta)+'</span>';
limpaPaineis(); // o carrinho só some se o pedido estiver vazio
}
},220);
}
function mostraNota(itens,n,titulo,sub){
nota=n;
$('qual').classList.add('on');
$('qNome').textContent=titulo; $('qSub').textContent=sub;
const semNota = n===0;
$('pct').innerHTML = semNota? '—<small>/100</small>' : n+'<small>/100</small>';
const c = semNota? 'var(--fraco)' : n>=85?'var(--verde)':n>=50?'var(--laranja)':'var(--vermelho)';
$('pct').style.color=c;
$('qbi').style.width=(semNota?0:n)+'%'; $('qbi').style.background=c;
$('qitens').innerHTML=itens.map(i=>
'<div class="qi '+i[0]+'"><span class="c">'+
(i[0]==='ok'?'✓':i[0]==='av'?'!':i[0]==='fix'?'↻':'×')+'</span>'+
'<div><b>'+escapeHTML(i[1])+'</b><span>'+escapeHTML(i[2])+'</span></div></div>').join('');
$('qmsg').style.display='none';
ressalvaResolucao();
carrinho();
}
// A resolução do arquivo não é a das artes dentro dele: um PDF em 300 DPI
// pode ter uma imagem inserida em 180, e é essa que sai serrilhada.
function ressalvaResolucao(){
const box=$('ciente'), chk=$('cienteOk');
let baixas=[], geral=300, recusadas=[];
if(ehFolha()){
// Uma régua só: o número que precifica é o número que bloqueia.
// Recusa quando a média por área fica abaixo de 96 DPI — a folha inteira não
// vale o filme. Se a média passa mas há peças ruins, é ressalva com aceite.
folhas.forEach((x,k)=>{
if(!x.an) return;
const r=x.an.res, cm=x.med? x.med.larg : larguraFilme();
const nome=(folhas.length>1? (k+1)+'. ':'')+x.f.name;
const extra = r ? {pior:r.pior, de:r.total,
quantas:r.quantasBaixas, parte:Math.round(r.fracBaixa*100),
quantasAviso:r.quantasAviso, parteAviso:Math.round(r.fracAviso*100)} : {};
if(x.an.dpi<DPI_RECUSA) recusadas.push(Object.assign({nome, dpi:x.an.dpi, cm}, extra));
else if(x.an.dpi<DPI_AVISA || (r&&r.quantasAviso))
baixas.push(Object.assign({nome, dpi:x.an.dpi, cm}, extra));
});
geral = folhas.some(x=>x.an) ? Math.max(...folhas.filter(x=>x.an).map(x=>x.an.dpi)) : 300;
}else{
const comTamanho=artes.filter(a=>a.cm>0);
recusadas=comTamanho.filter(a=>dpiDe(a)<DPI_RECUSA)
.map(a=>({nome:a.f.name, dpi:Math.round(dpiDe(a)), cm:a.cm}));
baixas=comTamanho.filter(a=>dpiDe(a)>=DPI_RECUSA && dpiDe(a)<DPI_AVISA)
.map(a=>({nome:a.f.name, dpi:Math.round(dpiDe(a)), cm:a.cm}));
const todas=comTamanho.map(a=>dpiDe(a));
geral = todas.length? Math.round(Math.max(...todas)) : 300;
}
// Abaixo de 96 DPI a sala não imprime. Não é desconto, é recusa —
// e não existe aceite que destrave.
if(recusadas.length){
$('cienteTxt').innerHTML=
'<p><b>A média da folha ficou abaixo de '+DPI_RECUSA+' DPI.</b> Nesse ponto a peça '+
'sai serrilhada a ponto de não valer o filme. Reenvie maior ou reduza a largura.</p>'+
'<div class="lista">'+recusadas.map(b=>'<div><b>'+escapeHTML(b.nome)+'</b> — '+
(b.quantas
? 'média de '+b.dpi+' DPI · pior imagem em '+b.pior+' DPI · '+b.quantas+
' de '+b.de+' imagens abaixo de '+DPI_RECUSA+' DPI, cobrindo '+b.parte+'% da arte'
: b.dpi+' DPI em '+n1(b.cm)+' cm · precisa de '+
Math.ceil(DPI_AVISA*b.cm/2.54)+' px de largura')+'</div>')
.join('')+'</div>';
$('cienteTit').textContent='Resolução abaixo do que imprimimos';
box.className='ciente on recusa'; chk.checked=false; $('qOk').disabled=true;
return;
}
$('cienteTit').textContent='Ressalva de resolução';
box.className='ciente';
if(!baixas.length){
box.classList.remove('on'); chk.checked=false; $('qOk').disabled=false; return;
}
const menor=baixas.reduce((p,b)=>b.dpi<p.dpi?b:p);
const media=Math.round(baixas.reduce((s,b)=>s+b.dpi,0)/baixas.length);
$('cienteTxt').innerHTML=
'<p><b>Parte da arte está abaixo de '+DPI_AVISA+' DPI.</b> Essas peças podem sair '+
'com traço serrilhado, e isso não tem conserto depois de impresso. '+
'O resto da folha sai normal.</p>'+
'<div class="lista">'+baixas.map(b=>'<div><b>'+escapeHTML(b.nome)+'</b> — '+
(b.quantasAviso
? 'média de '+b.dpi+' DPI · '+b.quantasAviso+' de '+b.de+' imagens abaixo de '+
DPI_AVISA+' DPI, cobrindo '+b.parteAviso+'% da arte'+
(b.quantas? ' · dessas, '+b.quantas+' abaixo de '+DPI_RECUSA+' DPI ('+
b.parte+'% da arte)' : '')
: b.dpi+' DPI'+(b.cm? ' em '+n1(b.cm)+' cm':''))+
'</div>').join('')+'</div>';
box.className='ciente on'; chk.checked=false; $('qOk').disabled=true;
}
$('cienteOk').addEventListener('change',e=>{
if($('ciente').classList.contains('recusa')) return; // recusa não se aceita
$('qOk').disabled=!e.target.checked;
});

107
local/static/site-sheet.js Normal file
View File

@@ -0,0 +1,107 @@
/* Site DTF — Reading a finished sheet from its alpha channel: pieces, gaps, waste.
Extracted verbatim from the single inline script in dtf-site.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── Análise real da folha: lê o canal alfa, separa cada ilha de arte e mede.
// Grade de 1 mm. Só roda em arquivo que o navegador abre; PDF precisa do servidor.
const MM=0.1; // grade fina: 1 mm
const CELULAS_MAX=1200000; // teto de custo da análise
// Folha grande vira grade mais grossa. Sem isso, 57 x 480 cm dá 2,7 milhões de
// células e o navegador congela antes de terminar.
function passoDe(larguraCm, alturaCm){
const fino=(larguraCm/MM)*(alturaCm/MM);
return fino<=CELULAS_MAX ? MM : MM*Math.sqrt(fino/CELULAS_MAX);
}
// Dilata a máscara em r células · usada para agrupar por distância real, não por caixa
function inchar(bits,cw,ch,r){
const a=new Uint8Array(cw*ch), b=new Uint8Array(cw*ch);
for(let y=0;y<ch;y++){ const o=y*cw;
for(let x=0;x<cw;x++){ if(!bits[o+x]) continue;
const x0=Math.max(0,x-r), x1=Math.min(cw-1,x+r);
for(let k=x0;k<=x1;k++) a[o+k]=1; } }
for(let x=0;x<cw;x++)
for(let y=0;y<ch;y++){ if(!a[y*cw+x]) continue;
const y0=Math.max(0,y-r), y1=Math.min(ch-1,y+r);
for(let k=y0;k<=y1;k++) b[k*cw+x]=1; }
return b;
}
// Componentes conexos de 8 vizinhos · devolve o mapa de rótulos e quantos grupos
function grupos(bits,cw,ch){
const marca=new Int32Array(cw*ch).fill(-1), fila=new Int32Array(cw*ch);
let n=0;
for(let p0=0;p0<bits.length;p0++){
if(!bits[p0]||marca[p0]>=0) continue;
const id=n++; let ini=0,fim=0; fila[fim++]=p0; marca[p0]=id;
while(ini<fim){
const p=fila[ini++], x=p%cw, y=(p/cw)|0;
for(let dy=-1;dy<=1;dy++) for(let dx=-1;dx<=1;dx++){
const nx=x+dx, ny=y+dy;
if(nx<0||ny<0||nx>=cw||ny>=ch) continue;
const q=ny*cw+nx;
if(bits[q]&&marca[q]<0){ marca[q]=id; fila[fim++]=q; }
}
}
}
return {marca,n};
}
// ── O que a folha permite medir, e só isso.
// Peça = pixels a menos de 2 mm entre si · letras de uma palavra ficam juntas.
// Encostadas = peças que só se unem quando a folga chega a 6 mm.
const R_PECA=1, R_PERTO=2; // peça: 2 mm · aviso de encostadas: 4 mm
function analisarFolha(img, larguraCm){
const alturaCmEst=larguraCm*img.height/img.width;
const passo=passoDe(larguraCm, alturaCmEst);
const cw=Math.max(8,Math.round(larguraCm/passo));
const ch=Math.max(8,Math.round(cw*img.height/img.width));
const cv=document.createElement('canvas'); cv.width=cw; cv.height=ch;
const ctx=cv.getContext('2d',{willReadFrequently:true});
ctx.clearRect(0,0,cw,ch); ctx.drawImage(img,0,0,cw,ch);
const d=ctx.getImageData(0,0,cw,ch).data;
const bits=new Uint8Array(cw*ch);
let opacos=0;
for(let i=0,j=3;i<bits.length;i++,j+=4) if(d[j]>10){ bits[i]=1; opacos++; }
// peças: agrupa pela distância real entre pixels
const gp=grupos(inchar(bits,cw,ch,R_PECA), cw, ch);
const caixas=[];
for(let p=0;p<bits.length;p++){
if(!bits[p]) continue;
const id=gp.marca[p]; if(id<0) continue;
const x=p%cw, y=(p/cw)|0;
const c=caixas[id] || (caixas[id]={x0:x,x1:x,y0:y,y1:y,area:0,id});
if(x<c.x0)c.x0=x; if(x>c.x1)c.x1=x; if(y<c.y0)c.y0=y; if(y>c.y1)c.y1=y;
c.area++;
}
const pecasTodas=caixas.filter(Boolean);
const limRes=Math.max(1, Math.round(0.2/passo)); // 2 mm na grade em uso
const residuos=pecasTodas.filter(o=>(o.x1-o.x0)<limRes && (o.y1-o.y0)<limRes);
const artes=pecasTodas.filter(o=>!((o.x1-o.x0)<limRes && (o.y1-o.y0)<limRes));
// encostadas: peças diferentes que se unem quando a folga chega a 6 mm
const gper=grupos(inchar(bits,cw,ch,R_PERTO), cw, ch);
const porPerto=new Map();
for(let p=0;p<bits.length;p++){
if(!bits[p]) continue;
const a=gp.marca[p], b=gper.marca[p];
if(a<0||b<0) continue;
if(!porPerto.has(b)) porPerto.set(b,new Set());
porPerto.get(b).add(a);
}
let encostadas=0;
porPerto.forEach(set=>{ if(set.size>1) encostadas+=set.size; });
return {
dpi: Math.round(img.width/(larguraCm/2.54)),
artes: artes.length,
residuos: residuos.length,
encostadas,
fundoChapado: opacos/(cw*ch) > 0.985,
aproveitamento: Math.max(1,Math.min(100,Math.round(opacos/(cw*ch)*100))),
alturaCm: ch*passo,
grade: +(passo*10).toFixed(1) // mm por célula analisada
};
}

112
local/static/site-upload.js Normal file
View File

@@ -0,0 +1,112 @@
/* Site DTF — Accepting files: the drop zone, the accept rules, first measurement.
Extracted verbatim from the single inline script in dtf-site.html.
Loaded as classic scripts in the order listed there: they share one global
scope and run top to bottom, exactly as the original did. */
// ── upload
// Untrusted filenames and recovered cart text must never become HTML.
function escapeHTML(value){
return String(value).replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
}
function recusa(lista){
const el=$('recusa'); if(!el) return;
if(!lista.length){ el.style.display='none'; el.innerHTML=''; return; }
const exts=escapeHTML([...new Set(lista.map(f=>extDe(f.name)))].join(', '));
el.style.display='block';
el.innerHTML = ehFolha()
? '<b>'+exts+' não dá para enviar aqui.</b> A folha pronta aceita PNG, JPG, TIFF, '+
'PDF, PSD, AI e CDR.'
: '<b>'+exts+' não dá para enviar aqui.</b> Artes avulsas precisam de PNG ou JPG com '+
'fundo transparente — é o que a montagem enxerga para encaixar. '+
'Se o seu arquivo é CDR, AI ou PSD, exporte em PNG ou use <b>Arquivo por metro</b>.';
}
const Z=$('zona'), I=$('inp');
Z.addEventListener('click',()=>I.click());
Z.addEventListener('keydown',e=>{if(e.key==='Enter'||e.key===' '){I.click();e.preventDefault();}});
['dragover','dragenter'].forEach(ev=>Z.addEventListener(ev,e=>{e.preventDefault();Z.classList.add('sobre')}));
['dragleave','drop'].forEach(ev=>Z.addEventListener(ev,e=>{
e.preventDefault(); Z.classList.remove('sobre');
if(ev==='drop') sel([...e.dataTransfer.files]);
}));
I.addEventListener('change',()=>sel([...I.files]));
function sel(fs){
if(!fs.length) return;
// Pixels cannot tell a loose artwork from a finished sheet, so the customer
// declares it by choosing the product in the selector above. The declaration is
// verified in medirFolha; the file never changes product or price on its own.
const regra = ehFolha() ? ACEITA.folha : ACEITA.avulsa;
const fora = fs.filter(f=>!regra.test(f.name));
fs = fs.filter(f=>regra.test(f.name));
recusa(fora);
if(!fs.length) return;
if(ehFolha()){
const auto=fs.filter(f=>AUTO.test(f.name)).length;
const manual=fs.length-auto;
if(caminho==='auto' && manual){
caminho='tabela';
avisoCam('<b>Trocamos o caminho para você.</b> Você marcou conferência automática, '+
'mas '+(manual>1? manual+' arquivos vieram':'o arquivo veio')+' em formato que não '+
'abrimos. O metro vai pela tabela, '+rs(TABELA[modo])+'.');
pintaCaminhos();
}else if(caminho==='tabela' && auto){
caminho='auto';
avisoCam('<b>Boa notícia.</b> Você marcou o caminho sem conferência, mas '+
(auto>1? 'os arquivos vieram':'o arquivo veio')+' em formato que a gente confere. '+
'Vamos avaliar e o metro pode cair até '+rs(pisoEscada())+'.');
pintaCaminhos();
}
const novas=fs.map(f=>({f, med:null, rep:1, m:0, semAnalise:null}));
folhas=folhas.concat(novas); // soma, não substitui
pintaFolha();
novas.forEach(x=>{
x.pct=5; pintaFolha();
medirFolha(x.f).then(md=>{
if(md && md.dpiFolha!=null && md.dpiFolha<DPI_RECUSA) return recusaFolha(x, md);
x.med=md; if(md) x.m=md.alt/100; x.pct=55; pintaFolha();
if(md && RENDERIZA.test(x.f.name)){
carregarImagem(x.f).then(img=>{
if(img){ x.previewSrc=img.src; try{ x.an=analisarFolha(img, md.larg); if(x.an) x.an.fonteDpi='arquivo'; }
catch(e){} }
x.pct=null; pintaFolha();
});
}else if(md && /\.pdf$/i.test(x.f.name)){
x.pct=70; pintaFolha();
rasterizarPdf(x.f, md.larg, md.alt).then(r=>{
if(r && r.erro){
x.semAnalise = r.erro + (r.semWorker
? ' · este navegador não deixa o leitor rodar em segundo plano'
: '');
}
if(r && r.tela){
try{
x.an=analisarFolha(r.tela, md.larg);
// vetor puro não tem resolução · nota máxima, e está certo
x.an.dpi = r.dpi==null ? DPI_AVISA : r.dpi;
x.an.vetor = r.dpi==null;
x.an.fonteDpi = r.dpi==null ? 'vetor' : 'imagens';
x.an.res = r.res || null;
}catch(e){}
}
x.pct=null; pintaFolha();
});
}else{ x.pct=null; pintaFolha(); }
});
});
return;
}
const novos=fs.map(f=>({f,px:px(f),cm:0,q:1,prop:1,giro:0,esp:false}));
artes=novos.concat(artes); // a mais recente fica no topo da fila
recemChegada=novos[0];
pintaArtes();
$('lista').scrollIntoView({behavior:'smooth',block:'nearest'});
Promise.all(novos.map(medir)).then(pintaArtes);
}
function medir(a){
return carregarImagem(a.f).then(img=>{
if(img){ a.prop=img.height/img.width; a.px=img.width; a.src=img.src; } else a.prop=a.prop||1;
return a;
});
}
// A metragem sai do arquivo, nunca do peso em bytes.
// imagem → proporção da imagem aplicada à largura do filme
// PDF → MediaBox da primeira página, em pontos (1 pt = 1/72 pol)
// TIFF, PSD, AI, CDR → o navegador não abre; o cliente informa o comprimento

View File

@@ -7,7 +7,9 @@ from .pricing import price, TIERS
class PricingTests(unittest.TestCase):
def test_every_grade_against_actual_site_javascript(self):
source = Path('dtf-site.html').read_text()
# The ladders live with the rest of the Site's behaviour; this test exists
# to prove the server agrees with whatever the customer is shown.
source = Path('local/static/site-config.js').read_text()
tiers = source.split('const FAIXAS=')[1].split('\n};',1)[0]+'\n}'
calculator = source.split('const cobrar=')[1].split(';',1)[0]
js = f'const FAIXAS={tiers};const MINIMO_M=1;const cobrar={calculator};'

View File

@@ -1,322 +0,0 @@
"""
Portal de recebimento de arte — roda na nuvem, aberto 24 horas.
Fluxo:
0. Tiny avisa por webhook que nasceu um pedido de DTF
1. portal cria token e manda o link no WhatsApp
2. cliente abre o link e pede uma URL pré-assinada
3. o navegador envia o arquivo DIRETO para o storage (não passa por aqui)
4. cliente avisa que terminou -> robô valida, repete, fatia e carimba
5. o agente da fábrica busca o que foi aprovado
Rodar:
uvicorn main:app --host 0.0.0.0 --port 8000
"""
from __future__ import annotations
import os
import uuid
from datetime import datetime, timedelta, timezone
import boto3
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException
from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from sqlmodel import Field, Session, SQLModel, create_engine, select
import preflight
import tiny
import whats
# --------------------------------------------------------------------------
DB = os.environ["DATABASE_URL"]
BUCKET = os.environ["S3_BUCKET"]
BASE_PORTAL = os.environ["BASE_PORTAL"] # https://arte.dropstaratacado.com.br
BASE_KANBAN = os.environ["BASE_KANBAN"] # http://servidor:8080
TOKEN_TINY = os.environ["WEBHOOK_TOKEN"] # segredo combinado com o Tiny
TOKEN_AGENTE = os.environ["AGENTE_TOKEN"]
VALIDADE_TOKEN_DIAS = 7
MAX_GB = 5 # limite do que os PCs da sala aguentam abrir
MAX_ARQUIVOS = 10
engine = create_engine(DB)
s3 = boto3.client("s3", endpoint_url=os.environ["S3_ENDPOINT"])
app = FastAPI(title="Portal de arte DTF")
# --------------------------------------------------------------------------
# Tabelas
# --------------------------------------------------------------------------
class Pedido(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
numero_tiny: str = Field(index=True, unique=True)
cliente: str
metros: float # metros comprados por arte
token: str = Field(index=True, unique=True)
token_expira: datetime
criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Arte(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
pedido_id: int = Field(foreign_key="pedido.id", index=True)
arquivo: str
bytes: int = 0
repeticoes: int = 1
status: str = "recebida" # recebida | aprovada | recusada
motivo: str = ""
avisos: str = ""
dpi_efetivo: float = 0
largura_cm: float = 0
metros_totais: float = 0
baixada: bool = False
criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
class Parte(SQLModel, table=True):
id: int | None = Field(default=None, primary_key=True)
arte_id: int = Field(foreign_key="arte.id", index=True)
ordem: int
metros: float
arquivo: str
carimbada: bool = False
SQLModel.metadata.create_all(engine)
def sessao():
with Session(engine) as s:
yield s
# --------------------------------------------------------------------------
# 0. Webhook do Tiny — pedido novo
# --------------------------------------------------------------------------
class PedidoTiny(BaseModel):
numero: str
cliente: str
telefone: str
metros: float
@app.post("/webhook/tiny")
def pedido_novo(
p: PedidoTiny,
background: BackgroundTasks,
x_token: str = Header(default=""),
s: Session = Depends(sessao),
):
if x_token != TOKEN_TINY:
raise HTTPException(401, "token inválido")
existente = s.exec(select(Pedido).where(Pedido.numero_tiny == p.numero)).first()
if existente:
return {"ok": True, "ja_existia": True}
pedido = Pedido(
numero_tiny=p.numero,
cliente=p.cliente,
metros=p.metros,
token=uuid.uuid4().hex,
token_expira=datetime.now(timezone.utc) + timedelta(days=VALIDADE_TOKEN_DIAS),
)
s.add(pedido)
s.commit()
s.refresh(pedido)
link = f"{BASE_PORTAL}/arte/{pedido.token}"
background.add_task(whats.enviar_link, p.telefone, p.numero, link)
return {"ok": True, "link": link}
# --------------------------------------------------------------------------
# 1 e 2. Página e URL pré-assinada
# --------------------------------------------------------------------------
def _pedido_por_token(token: str, s: Session) -> Pedido:
p = s.exec(select(Pedido).where(Pedido.token == token)).first()
if not p:
raise HTTPException(404, "link inválido")
if p.token_expira < datetime.now(timezone.utc):
raise HTTPException(410, "link expirado — fale com o atendimento")
return p
@app.get("/arte/{token}", response_class=HTMLResponse)
def pagina(token: str, s: Session = Depends(sessao)):
_pedido_por_token(token, s)
return open("static/portal.html", encoding="utf-8").read()
@app.get("/api/arte/{token}")
def dados(token: str, s: Session = Depends(sessao)):
p = _pedido_por_token(token, s)
return {
"pedido": p.numero_tiny,
"cliente": p.cliente,
"metros": p.metros,
"area_util_cm": preflight.AREA_UTIL_CM,
"limite_arquivo_m": preflight.LIMITE_ARQUIVO_M,
"max_gb": MAX_GB,
}
@app.post("/api/arte/{token}/url")
def url_upload(token: str, nome: str, s: Session = Depends(sessao)):
p = _pedido_por_token(token, s)
n = len(s.exec(select(Arte).where(Arte.pedido_id == p.id)).all())
if n >= MAX_ARQUIVOS:
raise HTTPException(400, f"máximo de {MAX_ARQUIVOS} arquivos por pedido")
chave = f"{p.numero_tiny}/{uuid.uuid4().hex}_{nome}"
url = s3.generate_presigned_url(
"put_object",
Params={"Bucket": BUCKET, "Key": chave},
ExpiresIn=3600,
)
return {"url": url, "chave": chave}
# --------------------------------------------------------------------------
# 4. Terminou o upload -> roda o pré-flight
# --------------------------------------------------------------------------
class Pronto(BaseModel):
chave: str
repeticoes: int = 1
@app.post("/api/arte/{token}/pronto")
def pronto(
token: str,
body: Pronto,
background: BackgroundTasks,
s: Session = Depends(sessao),
):
p = _pedido_por_token(token, s)
arte = Arte(pedido_id=p.id, arquivo=body.chave, repeticoes=max(1, body.repeticoes))
s.add(arte)
s.commit()
s.refresh(arte)
background.add_task(processar, arte.id)
return {"arte_id": arte.id, "status": "processando"}
def processar(arte_id: int):
"""Baixa do storage, valida, repete, fatia, carimba e devolve as partes."""
with Session(engine) as s:
arte = s.get(Arte, arte_id)
pedido = s.get(Pedido, arte.pedido_id)
local = f"/tmp/{arte.id}.png"
s3.download_file(BUCKET, arte.arquivo, local)
arte.bytes = os.path.getsize(local)
r = preflight.processar(
caminho=local,
cm_comprados=pedido.metros * 100,
repeticoes=arte.repeticoes,
pedido=pedido.numero_tiny,
base_kanban=BASE_KANBAN,
pasta_saida="/tmp",
)
arte.dpi_efetivo = r.dpi_efetivo
arte.largura_cm = r.largura_cm
arte.metros_totais = r.metros_totais
arte.avisos = " | ".join(r.avisos)
if not r.aprovado:
arte.status, arte.motivo = "recusada", r.motivo
s.add(arte)
s.commit()
whats.enviar_recusa(pedido, arte.motivo)
return
for i, caminho in enumerate(r.partes, start=1):
chave = f"{pedido.numero_tiny}/partes/{os.path.basename(caminho)}"
s3.upload_file(caminho, BUCKET, chave)
s.add(Parte(
arte_id=arte.id,
ordem=i,
metros=min(preflight.LIMITE_ARQUIVO_M,
r.metros_totais - preflight.LIMITE_ARQUIVO_M * (i - 1)),
arquivo=chave,
carimbada=(i == len(r.partes)),
))
arte.status = "aprovada"
s.add(arte)
s.commit()
tiny.marcador(pedido.numero_tiny, "DTF-RECEBIDA")
whats.enviar_aprovacao(pedido, r)
avisar_agente()
# --------------------------------------------------------------------------
# 5. O agente busca o que foi aprovado
# --------------------------------------------------------------------------
@app.get("/api/artes")
def para_baixar(x_token: str = Header(default=""), s: Session = Depends(sessao)):
if x_token != TOKEN_AGENTE:
raise HTTPException(401, "token inválido")
artes = s.exec(
select(Arte).where(Arte.status == "aprovada", Arte.baixada == False) # noqa: E712
).all()
saida = []
for a in artes:
p = s.get(Pedido, a.pedido_id)
partes = s.exec(select(Parte).where(Parte.arte_id == a.id)).all()
saida.append({
"arte_id": a.id,
"pedido": p.numero_tiny,
"cliente": p.cliente,
"metros": a.metros_totais,
"partes": [
{
"ordem": pt.ordem,
"metros": pt.metros,
"url": s3.generate_presigned_url(
"get_object",
Params={"Bucket": BUCKET, "Key": pt.arquivo},
ExpiresIn=3600,
),
"nome": os.path.basename(pt.arquivo),
}
for pt in sorted(partes, key=lambda x: x.ordem)
],
})
return saida
@app.post("/api/artes/{arte_id}/baixada")
def marcar_baixada(
arte_id: int, x_token: str = Header(default=""), s: Session = Depends(sessao)
):
if x_token != TOKEN_AGENTE:
raise HTTPException(401, "token inválido")
a = s.get(Arte, arte_id)
a.baixada = True
s.add(a)
s.commit()
return {"ok": True}
def avisar_agente():
"""Webhook para o agente na fábrica. O polling de 60s é a rede de segurança."""
import httpx
url = os.environ.get("AGENTE_WEBHOOK")
if not url:
return
try:
httpx.post(url, timeout=5, headers={"x-token": TOKEN_AGENTE})
except Exception:
pass # o polling pega
@app.get("/saude")
def saude():
return {"ok": True, "em": datetime.now(timezone.utc).isoformat()}

View File

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

View File

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

View File

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

View File

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