Compare commits
3 Commits
86b8199612
...
66ddb17f02
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
66ddb17f02 | ||
|
|
96f1d27221 | ||
|
|
ca698434a2 |
27
.env.exemplo
27
.env.exemplo
@@ -1,27 +0,0 @@
|
||||
# HISTORICAL PROTOTYPE ONLY. Do not use for the local stack; use .env.example.
|
||||
# ---- portal (nuvem) ----
|
||||
DATABASE_URL=postgresql+psycopg://dtf:senha@localhost/dtf
|
||||
S3_ENDPOINT=https://<conta>.r2.cloudflarestorage.com
|
||||
S3_BUCKET=dtf-artes
|
||||
AWS_ACCESS_KEY_ID=
|
||||
AWS_SECRET_ACCESS_KEY=
|
||||
BASE_PORTAL=https://arte.dropstaratacado.com.br
|
||||
BASE_KANBAN=http://servidor:8080
|
||||
WEBHOOK_TOKEN=troque-isto
|
||||
AGENTE_TOKEN=troque-isto-tambem
|
||||
AGENTE_WEBHOOK=http://ip-da-fabrica:8080/api/agente/acordar
|
||||
|
||||
# ---- Tiny ----
|
||||
TINY_CLIENT_ID=
|
||||
TINY_CLIENT_SECRET=
|
||||
TINY_TOKEN_URL=
|
||||
TINY_BASE=https://api.tiny.com.br/public-api/v3
|
||||
|
||||
# ---- WhatsApp ----
|
||||
WHATS_PROVEDOR=meta
|
||||
WHATS_TOKEN=
|
||||
WHATS_NUMERO_ID=
|
||||
|
||||
# ---- fábrica ----
|
||||
PASTA_DTF=\\servidor\DTF
|
||||
KANBAN_DB=C:\dtf\kanban.db
|
||||
14
.gitignore
vendored
14
.gitignore
vendored
@@ -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/
|
||||
|
||||
30
CONTEXT.md
30
CONTEXT.md
@@ -4,7 +4,7 @@
|
||||
|
||||
Read this document before changing code, documentation, architecture, or scope.
|
||||
It is the current English source of truth for the project. It supersedes older
|
||||
decisions in `README.md`, `ESPECIFICACAO.md`, `schema.sql`, and the existing
|
||||
decisions in `README.md`, `docs/historico/ESPECIFICACAO.md`, `schema.sql`, and the existing
|
||||
prototype code whenever they conflict.
|
||||
|
||||
Update this file whenever the team makes a material product, process, or
|
||||
@@ -340,7 +340,7 @@ as references and are not imported or started by Compose.
|
||||
activation remain incomplete. Deployment plumbing exists but cannot pass its
|
||||
release gate yet. No factory automation or pre-flight is added.
|
||||
|
||||
See `IMPLEMENTATION_REPORT.md` for verification, `PRODUCTION_INPUTS.md` for the
|
||||
See `docs/historico/IMPLEMENTATION_REPORT.md` for verification, `PRODUCTION_INPUTS.md` for the
|
||||
decisions and evidence required before staging or production connection, and
|
||||
`PORTAINER.md` for the production delivery contract.
|
||||
The client roadmap promises are unchanged, so its PDF source is not regenerated
|
||||
@@ -348,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.
|
||||
|
||||
10
README.md
10
README.md
@@ -14,6 +14,16 @@
|
||||
> a arquitetura, o código e o que ainda depende de resposta.
|
||||
> Base: conversas de 29 e 30/08/2026 com Marcus.
|
||||
|
||||
|
||||
> **2026-09-21.** Everything below this line is the original prototype
|
||||
> documentation. The code it describes (`portal/`, `kanban/`, `agente/`, the root
|
||||
> `schema.sql`, `.env.exemplo`) no longer exists, and the model it describes is
|
||||
> not the one implemented. Kept for the business reasoning in it — the capacity
|
||||
> figures, the cost argument, the meeting decisions. For how the system actually
|
||||
> works read [CONTEXT.md](CONTEXT.md); for what is outstanding read
|
||||
> [ROADMAP.md](ROADMAP.md); for the original documents see
|
||||
> [docs/historico/](docs/historico/README.md).
|
||||
|
||||
---
|
||||
|
||||
## Por que este projeto existe
|
||||
|
||||
26
ROADMAP.md
26
ROADMAP.md
@@ -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
|
||||
|
||||
141
agente/agente.py
141
agente/agente.py
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
Agente da fábrica — traz a arte aprovada do portal para o servidor local.
|
||||
|
||||
Roda como serviço:
|
||||
Windows: nssm install DtfAgente "C:\\Python312\\python.exe" "C:\\dtf\\agente.py"
|
||||
Linux: systemd (ver dtf-agente.service no repositório)
|
||||
|
||||
Duas fontes de trabalho:
|
||||
- webhook do portal (chega na hora)
|
||||
- polling a cada 60 s (rede de segurança se o webhook falhar)
|
||||
|
||||
Se a internet cair, o agente para e o portal continua recebendo.
|
||||
Quando voltar, ele baixa o acumulado. Nada se perde.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import sqlite3
|
||||
import time
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
|
||||
import httpx
|
||||
|
||||
PORTAL = os.environ["BASE_PORTAL"]
|
||||
TOKEN = os.environ["AGENTE_TOKEN"]
|
||||
RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF"))
|
||||
BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db"))
|
||||
INTERVALO = 60
|
||||
HEARTBEAT = 300
|
||||
|
||||
PASTA_ENTRADA = RAIZ / "00_ARTE_RECEBIDA"
|
||||
|
||||
logging.basicConfig(
|
||||
filename=RAIZ / "_log" / "agente.log",
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(message)s",
|
||||
)
|
||||
log = logging.getLogger("agente")
|
||||
|
||||
|
||||
def sha256(caminho: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(caminho, "rb") as f:
|
||||
for bloco in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(bloco)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def registrar_no_kanban(arte: dict, arquivos: list[Path]) -> None:
|
||||
"""Insere o card. Idempotente: a chave é o arte_id, não o nome do arquivo."""
|
||||
con = sqlite3.connect(BANCO)
|
||||
try:
|
||||
con.execute("""
|
||||
CREATE TABLE IF NOT EXISTS card(
|
||||
arte_id INTEGER PRIMARY KEY,
|
||||
pedido TEXT, cliente TEXT, metros REAL,
|
||||
coluna TEXT DEFAULT 'rec', desde TEXT,
|
||||
maquina TEXT, partes INTEGER
|
||||
)""")
|
||||
con.execute("""
|
||||
INSERT OR IGNORE INTO card(arte_id,pedido,cliente,metros,desde,partes)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(arte["arte_id"], arte["pedido"], arte["cliente"],
|
||||
arte["metros"], datetime.now().isoformat(timespec="seconds"),
|
||||
len(arquivos)))
|
||||
con.commit()
|
||||
finally:
|
||||
con.close()
|
||||
|
||||
|
||||
def baixar(cliente: httpx.Client, arte: dict) -> bool:
|
||||
PASTA_ENTRADA.mkdir(parents=True, exist_ok=True)
|
||||
salvos: list[Path] = []
|
||||
|
||||
for parte in arte["partes"]:
|
||||
destino = PASTA_ENTRADA / f"{arte['pedido']}_{parte['nome']}"
|
||||
if destino.exists():
|
||||
salvos.append(destino)
|
||||
continue
|
||||
tmp = destino.with_suffix(".parcial")
|
||||
with cliente.stream("GET", parte["url"]) as r:
|
||||
r.raise_for_status()
|
||||
with open(tmp, "wb") as f:
|
||||
for bloco in r.iter_bytes(1 << 20):
|
||||
f.write(bloco)
|
||||
tmp.rename(destino) # só aparece na pasta quando está completo
|
||||
salvos.append(destino)
|
||||
log.info("baixado %s (%s) sha=%s", destino.name,
|
||||
f"{parte['metros']:.2f} m", sha256(destino)[:12])
|
||||
|
||||
registrar_no_kanban(arte, salvos)
|
||||
cliente.post(f"{PORTAL}/api/artes/{arte['arte_id']}/baixada",
|
||||
headers={"x-token": TOKEN}, timeout=15)
|
||||
return True
|
||||
|
||||
|
||||
def ciclo(cliente: httpx.Client) -> int:
|
||||
r = cliente.get(f"{PORTAL}/api/artes", headers={"x-token": TOKEN}, timeout=30)
|
||||
r.raise_for_status()
|
||||
artes = r.json()
|
||||
for arte in artes:
|
||||
try:
|
||||
baixar(cliente, arte)
|
||||
except Exception as e:
|
||||
log.error("falha na arte %s: %s", arte["arte_id"], e)
|
||||
return len(artes)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
log.info("agente iniciado · portal=%s · pasta=%s", PORTAL, RAIZ)
|
||||
ultimo_hb = 0.0
|
||||
with httpx.Client(follow_redirects=True) as cliente:
|
||||
while True:
|
||||
try:
|
||||
n = ciclo(cliente)
|
||||
if n:
|
||||
log.info("%d arte(s) processada(s)", n)
|
||||
except httpx.HTTPError as e:
|
||||
# internet caiu: não é erro fatal, o portal segue recebendo
|
||||
log.warning("sem conexão com o portal: %s", e)
|
||||
except Exception as e:
|
||||
log.exception("erro inesperado: %s", e)
|
||||
|
||||
agora = time.time()
|
||||
if agora - ultimo_hb > HEARTBEAT:
|
||||
try:
|
||||
cliente.post(f"{PORTAL}/api/agente/heartbeat",
|
||||
headers={"x-token": TOKEN}, timeout=10)
|
||||
ultimo_hb = agora
|
||||
except Exception:
|
||||
pass # sem sinal por 15 min, o portal alerta o TI
|
||||
|
||||
time.sleep(INTERVALO)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,14 +0,0 @@
|
||||
[Unit]
|
||||
Description=Agente DTF - baixa artes do portal
|
||||
After=network-online.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
User=dtf
|
||||
EnvironmentFile=/etc/dtf/.env
|
||||
ExecStart=/usr/bin/python3 /opt/dtf/agente.py
|
||||
Restart=always
|
||||
RestartSec=15
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
15
docs/README.md
Normal file
15
docs/README.md
Normal 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
26
docs/historico/README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# Historical documents
|
||||
|
||||
These describe the project as it was originally designed: a Tiny-first flow with
|
||||
token upload links, a factory-side agent, a local SQLite Kanban, FlexiPRINT
|
||||
automation, 90-day retention and 12-month artwork reuse.
|
||||
|
||||
**None of it describes the system that exists.** `CONTEXT.md` is the current
|
||||
source of truth and `ROADMAP.md` tracks outstanding work. These are kept because
|
||||
they record decisions and reasoning that the current documents do not repeat —
|
||||
read them for background, never as instructions.
|
||||
|
||||
| File | What it was |
|
||||
|---|---|
|
||||
| `ESPECIFICACAO.md` | Original specification of the Tiny-first model |
|
||||
| `API.md` | Endpoint sketch for the prototype portal |
|
||||
| `TAREFAS.md` | Task plan written before the current scope |
|
||||
| `IMPLEMENTATION_REPORT.md` | Status report for the prototype milestone |
|
||||
| `dtf-organograma-*` | Organisation charts, referenced by nothing |
|
||||
|
||||
The prototype code they describe — `portal/`, `kanban/`, `agente/`, the root
|
||||
`schema.sql` and `.env.exemplo` — was removed once nothing referenced it. Recover
|
||||
it from Git history if it is ever needed:
|
||||
|
||||
```bash
|
||||
git log --oneline --diff-filter=D -- portal kanban agente
|
||||
```
|
||||
1587
dtf-site.html
1587
dtf-site.html
File diff suppressed because it is too large
Load Diff
557
kanban/main.py
557
kanban/main.py
@@ -1,557 +0,0 @@
|
||||
"""
|
||||
Kanban da sala de DTF — roda no servidor da fábrica, rede interna.
|
||||
|
||||
Princípio: o KANBAN é a única interface. O operador move o card aqui e as
|
||||
pastas do servidor seguem sozinhas — ninguém abre o Explorer.
|
||||
Decidido em 30/08/2026: não existe watchdog de pastas. Movimento feito fora do
|
||||
kanban não é suportado.
|
||||
|
||||
A fila é sempre por HORÁRIO DE CHEGADA: quem chegou primeiro fica em cima.
|
||||
Mover o card grava o movimento, move o arquivo, enfileira o marcador no Tiny
|
||||
e o WhatsApp ao cliente. Ninguém abre o Tiny para atualizar nada.
|
||||
|
||||
O log de movimentos é gravado SEMPRE, mesmo se o Tiny estiver fora do ar.
|
||||
A medição de tempo por etapa nunca depende de API de terceiro.
|
||||
|
||||
Rodar:
|
||||
uvicorn main:app --host 0.0.0.0 --port 8080
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sqlite3
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from fastapi.responses import FileResponse, RedirectResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
import tiny
|
||||
import whats
|
||||
|
||||
BANCO = Path(os.environ.get("KANBAN_DB", r"C:\dtf\kanban.db"))
|
||||
RAIZ = Path(os.environ.get("PASTA_DTF", r"\\servidor\DTF"))
|
||||
|
||||
# O KANBAN é a fonte de verdade da operação. O marcador no Tiny existe só para
|
||||
# quem NÃO abre o kanban: comercial atendendo cliente no telefone, expedição
|
||||
# conferindo se o DTF saiu, e o Marcus olhando pelo celular fora da fábrica.
|
||||
#
|
||||
# Por isso o Tiny recebe o estágio grosso, não o detalhe fino:
|
||||
# está sendo feito · travou · ficou pronto
|
||||
#
|
||||
# Três chamadas por pedido em vez de sete. A 213 pedidos/dia, são 640 requisições
|
||||
# em vez de 1.500 — folga no limite da API e menos job para o TI monitorar.
|
||||
COLUNAS = [
|
||||
("aut", "Aguardando autorização", "05_AUTORIZACAO", None, False), # só retrabalho
|
||||
("rec", "Arte recebida", "00_ARTE_RECEBIDA", None, False),
|
||||
("tra", "Arte tratada", "10_ARTE_TRATADA", None, False),
|
||||
("apc", "Aprovação de cor", "15_APROVACAO_COR", "DTF-PROVA-COR",False),
|
||||
("fil", "Fila de impressão", "20_FILA_IMPRESSAO", None, False),
|
||||
("imp", "Imprimindo", "30_IMPRIMINDO", "DTF-PRODUCAO", False),
|
||||
("cor", "Correção", "40_CORRECAO", "CORRECAO DTF", False),
|
||||
("fin", "Finalizado", "60_FINALIZADO", "DTF-PRONTO", False),
|
||||
]
|
||||
# A sala só IMPRIME o filme — quem aplica na peça é o cliente.
|
||||
# Por isso não existe coluna de aplicação nem causa de retrabalho por aplicação.
|
||||
IDS = [c[0] for c in COLUNAS]
|
||||
INFO = {c[0]: c for c in COLUNAS}
|
||||
AVISA_CLIENTE = {"apc": "prova_cor", "imp": "producao",
|
||||
"cor": "correcao", "fin": "concluido"}
|
||||
|
||||
app = FastAPI(title="Kanban DTF")
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Distribuição entre as máquinas — POR PUXADA, não por empurro
|
||||
# ---------------------------------------------------------------------------
|
||||
# O sistema NÃO decide qual máquina roda qual pedido. A fila é única, por
|
||||
# horário de chegada, e a máquina que termina puxa o próximo.
|
||||
#
|
||||
# Empurrar exigiria adivinhar qual máquina estará livre daqui a duas horas. Se
|
||||
# uma travar, a fila dela para enquanto outra fica ociosa, e alguém remaneja na
|
||||
# mão. Por puxada nunca desbalanceia.
|
||||
#
|
||||
# Para o operador: um botão "puxar próximo". O arquivo já vem com o spot pronto
|
||||
# do robô — ele só abre no Flexi e roda.
|
||||
# Ajustado em 02/09/2026 pelo Thales e Alexandre:
|
||||
# "reserva de 15 min é demais, no máx 3 min"
|
||||
# "pedido por vez" — não puxar lote de trabalho
|
||||
RESERVA_MIN = 3 # sem entrar em Imprimindo nesse prazo, volta para a fila
|
||||
PUXAR_ATE_MIN = 0 # 0 = um pedido por vez, como a sala pediu
|
||||
|
||||
|
||||
class Puxar(BaseModel):
|
||||
maquina: int
|
||||
usuario: str
|
||||
minutos: int = PUXAR_ATE_MIN
|
||||
|
||||
|
||||
@app.post("/api/puxar")
|
||||
def puxar(p: Puxar):
|
||||
"""
|
||||
A máquina puxa o próximo da fila. Reserva por 15 min e move o arquivo para
|
||||
a hot folder daquela máquina — o FlexiPRINT processa sozinho de lá.
|
||||
"""
|
||||
agora = datetime.now()
|
||||
limite = (agora - timedelta(minutes=RESERVA_MIN)).isoformat(timespec="seconds")
|
||||
pegos, minutos = [], 0
|
||||
|
||||
with con() as c:
|
||||
# solta reservas vencidas antes de distribuir
|
||||
c.execute("UPDATE card SET reservado_por=NULL, reservado_em=NULL "
|
||||
"WHERE reservado_em IS NOT NULL AND reservado_em < ?", (limite,))
|
||||
|
||||
fila = c.execute(
|
||||
"SELECT * FROM card WHERE coluna='fil' AND reservado_por IS NULL "
|
||||
"ORDER BY desde").fetchall() # sempre por horário de chegada
|
||||
|
||||
for card in fila:
|
||||
# estimativa de máquina: o campo do card manda; sem ele, calcula pelos metros
|
||||
mm = card["minutos_maquina"] or round(card["metros"] / 20 * 60)
|
||||
if pegos: # um pedido por vez
|
||||
break
|
||||
c.execute("UPDATE card SET reservado_por=?, reservado_em=?, maquina=? "
|
||||
"WHERE arte_id=?",
|
||||
(f"Maq {p.maquina}", agora.isoformat(timespec="seconds"),
|
||||
f"Maq {p.maquina}", card["arte_id"]))
|
||||
mover_arquivos(card["pedido"], "20_FILA_IMPRESSAO", f"30_MAQ{p.maquina}")
|
||||
pegos.append({"pedido": card["pedido"], "metros": card["metros"],
|
||||
"minutos": mm})
|
||||
minutos += mm
|
||||
|
||||
return {"maquina": f"Maq {p.maquina}", "pedidos": pegos,
|
||||
"minutos_total": minutos,
|
||||
"reserva_expira_em": RESERVA_MIN}
|
||||
|
||||
|
||||
@app.post("/api/devolver")
|
||||
def devolver(arte_id: int, usuario: str):
|
||||
"""
|
||||
Máquina travou no meio. O pedido volta ao TOPO da fila, não ao fim —
|
||||
ele já esperou uma vez.
|
||||
"""
|
||||
with con() as c:
|
||||
card = c.execute("SELECT * FROM card WHERE arte_id=?", (arte_id,)).fetchone()
|
||||
if not card:
|
||||
raise HTTPException(404)
|
||||
maq = (card["maquina"] or "Maq 1").replace("Maq ", "")
|
||||
mover_arquivos(card["pedido"], f"30_MAQ{maq}", "20_FILA_IMPRESSAO")
|
||||
# desde antigo = volta para o topo da ordem por horário de chegada
|
||||
c.execute("UPDATE card SET coluna='fil', reservado_por=NULL, "
|
||||
"reservado_em=NULL, maquina=NULL WHERE arte_id=?", (arte_id,))
|
||||
c.execute("""INSERT INTO movimento
|
||||
(arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(arte_id, card["pedido"], "imp", "fil", card["desde"],
|
||||
datetime.now().isoformat(timespec="seconds"), 0, usuario, card["maquina"]))
|
||||
return {"ok": True, "voltou_ao_topo": True}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Aprovação de cor — evita o retrabalho em vez de repor depois
|
||||
# ---------------------------------------------------------------------------
|
||||
# Imprime uma amostra pequena, fotografa e manda ao cliente. Ele aprova, e só
|
||||
# então o pedido vai para a fila. Custa centímetros de filme e evita metros.
|
||||
#
|
||||
# Dispara sozinho em três situações:
|
||||
PROVA_METROS = 10.0 # arquivo grande: erro de cor custa caro
|
||||
PROVA_CLIENTE_NOVO = True # primeiro pedido do cliente
|
||||
# cores fora do gamut CMYK, que quase nunca saem como o cliente vê na tela
|
||||
PROVA_CORES_TENSAS = {
|
||||
"vermelho_saturado", "laranja", "verde_vivo", "azul_royal",
|
||||
"roxo", "tom_de_pele", "cinza_neutro",
|
||||
}
|
||||
|
||||
|
||||
def precisa_prova_cor(metros: float, cliente_novo: bool,
|
||||
cores_detectadas: set[str]) -> tuple[bool, str]:
|
||||
if metros >= PROVA_METROS:
|
||||
return True, f"arquivo de {metros:.1f} m"
|
||||
if cliente_novo and PROVA_CLIENTE_NOVO:
|
||||
return True, "primeiro pedido do cliente"
|
||||
tensas = cores_detectadas & PROVA_CORES_TENSAS
|
||||
if tensas:
|
||||
return True, "cor fora do gamut: " + ", ".join(sorted(tensas))
|
||||
return False, ""
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Retrabalho — SKU CRRMP.TX.100CM
|
||||
# ---------------------------------------------------------------------------
|
||||
# A causa é obrigatória e define quem absorve o custo. Sem ela o card não anda.
|
||||
CAUSAS = {
|
||||
"arte_cliente": ("Arte ruim aprovada pelo cliente", "cliente"),
|
||||
"tratamento": ("Erro de tratamento", "casa"),
|
||||
"impressao": ("Falha de impressão", "casa"),
|
||||
"perfil_cor": ("Perfil de cor", "casa"),
|
||||
"pedido": ("Erro de pedido", "casa"),
|
||||
}
|
||||
|
||||
# Quem abre e quem autoriza são pessoas diferentes, de propósito.
|
||||
#
|
||||
# MAYANA abre e CLASSIFICA a causa. Ela conhece o cliente e sabe se a
|
||||
# reclamação procede. A causa fica TRAVADA depois de aberta.
|
||||
#
|
||||
# THALES ou ALEXANDRE autorizam. O retrabalho da casa entra na meta e na
|
||||
# remuneração deles — então têm incentivo real para questionar o que não
|
||||
# procede. É controle natural, não burocracia.
|
||||
#
|
||||
# Por que a causa não pode ser mudada por quem autoriza: com o indicador
|
||||
# atrelado a bônus, existiria incentivo para reclassificar falha de máquina
|
||||
# como "arte do cliente". Quem discorda CONTESTA, e a contestação fica
|
||||
# registrada com quem pediu e quem decidiu.
|
||||
ABRE = "mayana"
|
||||
AUTORIZA = ("thales", "alexandre")
|
||||
|
||||
# Alçada por metragem. Cada metro custa R$ 4,94 de insumo mais tempo de máquina.
|
||||
ALCADA = [(15.0, "sala"), (float("inf"), "financeiro")]
|
||||
# Meta por causa, não meta única. Cada um responde pelo que controla.
|
||||
# 0,4% em cada uma das quatro causas da casa = 1,6% no total.
|
||||
#
|
||||
# Perfil de cor é causa PRÓPRIA, separada de falha de impressão. Cor errada é o
|
||||
# retrabalho mais comum de DTF e quase nunca é defeito de máquina: ou a arte
|
||||
# veio em CMYK, ou o monitor do cliente não é calibrado, ou o perfil da
|
||||
# impressora está desatualizado. Só o terceiro é da casa — e separando dá para
|
||||
# saber quanto é cada coisa.
|
||||
META_POR_CAUSA = {
|
||||
"impressao": (0.4, "Thales e Alexandre"),
|
||||
"perfil_cor": (0.4, "Thales e Alexandre"),
|
||||
"tratamento": (0.4, "designers"),
|
||||
"pedido": (0.4, "comercial"),
|
||||
"arte_cliente": (None, None), # reposição comercial: fora da meta
|
||||
}
|
||||
# soma das metas da casa = 1,6%
|
||||
TETO_MES_PCT = 6.0 # acima disso tudo sobe uma alçada
|
||||
TETO_CLIENTE_PCT = 10.0 # cliente acima disso fica sinalizado no painel
|
||||
|
||||
|
||||
def quem_autoriza(metros: float, vez: int, pct_mes: float) -> str:
|
||||
"""Reincidência e teto do mês sobem a alçada automaticamente."""
|
||||
if vez >= 3:
|
||||
return "diretoria"
|
||||
nivel = next(n for lim, n in ALCADA if metros <= lim)
|
||||
if vez == 2:
|
||||
nivel = {"sala": "financeiro"}.get(nivel, "diretoria")
|
||||
if pct_mes > TETO_MES_PCT:
|
||||
nivel = {"sala": "financeiro", "financeiro": "diretoria"}.get(nivel, "diretoria")
|
||||
return nivel
|
||||
|
||||
|
||||
def conta_na_meta(causa: str) -> bool:
|
||||
"""Só o retrabalho da casa entra na meta. O do cliente, não."""
|
||||
return CAUSAS[causa][1] == "casa"
|
||||
|
||||
|
||||
@app.get("/api/relatorio/retrabalho")
|
||||
def retrabalho(dias: int = 30):
|
||||
"""
|
||||
Retrabalho por causa, com responsável e meta.
|
||||
Base para a bonificação: cada equipe é medida só pelo que controla.
|
||||
"""
|
||||
with con() as c:
|
||||
total = c.execute(
|
||||
"SELECT COUNT(*) n FROM card WHERE desde >= date('now', ?)",
|
||||
(f"-{dias} days",)).fetchone()["n"] or 1
|
||||
linhas = c.execute("""
|
||||
SELECT causa, COUNT(*) n, SUM(metros) m
|
||||
FROM retrabalho WHERE aberto_em >= date('now', ?)
|
||||
GROUP BY causa""", (f"-{dias} days",)).fetchall()
|
||||
|
||||
saida, casa = [], 0.0
|
||||
for r in linhas:
|
||||
pct = r["n"] / total * 100
|
||||
meta, quem = META_POR_CAUSA.get(r["causa"], (None, None))
|
||||
if conta_na_meta(r["causa"]):
|
||||
casa += pct
|
||||
saida.append({
|
||||
"causa": r["causa"], "descricao": CAUSAS[r["causa"]][0],
|
||||
"responsavel": quem, "pedidos": r["n"], "metros": r["m"],
|
||||
"pct": round(pct, 2), "meta": meta,
|
||||
"bate": None if meta is None else pct <= meta,
|
||||
})
|
||||
return {"por_causa": saida, "total_casa_pct": round(casa, 2),
|
||||
"meta_casa_pct": round(sum(m for m, _ in META_POR_CAUSA.values()
|
||||
if m is not None), 2)}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
def con():
|
||||
c = sqlite3.connect(BANCO, timeout=10)
|
||||
c.row_factory = sqlite3.Row
|
||||
return c
|
||||
|
||||
|
||||
def criar_tabelas() -> None:
|
||||
with con() as c:
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS card(
|
||||
arte_id INTEGER PRIMARY KEY, pedido TEXT, cliente TEXT, metros REAL,
|
||||
coluna TEXT DEFAULT 'rec', desde TEXT, maquina TEXT, partes INTEGER,
|
||||
reservado_por TEXT, reservado_em TEXT,
|
||||
minutos_maquina INTEGER); -- estimativa; herda os marcadores 30min/1h/3h
|
||||
CREATE TABLE IF NOT EXISTS movimento(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
arte_id INTEGER, pedido TEXT, de TEXT, para TEXT,
|
||||
entrou_em TEXT, saiu_em TEXT, segundos INTEGER,
|
||||
usuario TEXT, maquina TEXT);
|
||||
CREATE TABLE IF NOT EXISTS job(
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tipo TEXT, payload TEXT, tentativas INTEGER DEFAULT 0,
|
||||
proxima_em REAL, erro TEXT, feito INTEGER DEFAULT 0);
|
||||
CREATE INDEX IF NOT EXISTS ix_mov_arte ON movimento(arte_id);
|
||||
CREATE INDEX IF NOT EXISTS ix_job_fila ON job(feito, proxima_em);
|
||||
""")
|
||||
|
||||
|
||||
criar_tabelas()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
class Mover(BaseModel):
|
||||
arte_id: int
|
||||
para: str
|
||||
usuario: str
|
||||
maquina: str | None = None
|
||||
|
||||
|
||||
@app.get("/api/quadro")
|
||||
def quadro():
|
||||
agora = datetime.now()
|
||||
with con() as c:
|
||||
cards = c.execute("SELECT * FROM card ORDER BY desde").fetchall()
|
||||
|
||||
saida = {i: [] for i in IDS}
|
||||
for r in cards:
|
||||
desde = datetime.fromisoformat(r["desde"])
|
||||
saida[r["coluna"]].append({
|
||||
"arte_id": r["arte_id"], "pedido": r["pedido"], "cliente": r["cliente"],
|
||||
"metros": r["metros"], "maquina": r["maquina"], "partes": r["partes"],
|
||||
"desde": r["desde"],
|
||||
"parado_seg": int((agora - desde).total_seconds()),
|
||||
"minutos_maquina": round(r["metros"] / 20 * 60), # 20 m/h por máquina
|
||||
})
|
||||
return {"colunas": [{"id": c[0], "nome": c[1]} for c in COLUNAS], "cards": saida}
|
||||
|
||||
|
||||
@app.post("/api/mover")
|
||||
def mover(m: Mover):
|
||||
if m.para not in IDS:
|
||||
raise HTTPException(400, "coluna inválida")
|
||||
|
||||
agora = datetime.now()
|
||||
with con() as c:
|
||||
card = c.execute("SELECT * FROM card WHERE arte_id=?", (m.arte_id,)).fetchone()
|
||||
if not card:
|
||||
raise HTTPException(404, "card não encontrado")
|
||||
if card["coluna"] == m.para:
|
||||
return {"ok": True, "sem_mudanca": True}
|
||||
|
||||
de = card["coluna"]
|
||||
desde = datetime.fromisoformat(card["desde"])
|
||||
|
||||
# 1. o movimento é gravado ANTES de qualquer integração externa
|
||||
c.execute("""INSERT INTO movimento
|
||||
(arte_id,pedido,de,para,entrou_em,saiu_em,segundos,usuario,maquina)
|
||||
VALUES (?,?,?,?,?,?,?,?,?)""",
|
||||
(m.arte_id, card["pedido"], de, m.para, card["desde"],
|
||||
agora.isoformat(timespec="seconds"),
|
||||
int((agora - desde).total_seconds()), m.usuario,
|
||||
m.maquina or card["maquina"]))
|
||||
|
||||
c.execute("UPDATE card SET coluna=?, desde=?, maquina=COALESCE(?,maquina) "
|
||||
"WHERE arte_id=?",
|
||||
(m.para, agora.isoformat(timespec="seconds"), m.maquina, m.arte_id))
|
||||
|
||||
# 2. arquivo acompanha o card
|
||||
mover_arquivos(card["pedido"], INFO[de][2], INFO[m.para][2])
|
||||
|
||||
# 3. jobs: marcador no Tiny e WhatsApp
|
||||
# a máquina fica só no kanban — no Tiny basta saber que entrou em produção
|
||||
marcador = INFO[m.para][3]
|
||||
if marcador:
|
||||
enfileirar(c, "tiny_marcador",
|
||||
{"pedido": card["pedido"], "marcador": marcador})
|
||||
if m.para in AVISA_CLIENTE:
|
||||
enfileirar(c, "whats",
|
||||
{"pedido": card["pedido"], "tipo": AVISA_CLIENTE[m.para]})
|
||||
|
||||
return {"ok": True, "de": de, "para": m.para}
|
||||
|
||||
|
||||
def mover_arquivos(pedido: str, pasta_de: str, pasta_para: str) -> None:
|
||||
origem, destino = RAIZ / pasta_de, RAIZ / pasta_para
|
||||
destino.mkdir(parents=True, exist_ok=True)
|
||||
for f in origem.glob(f"{pedido}_*"):
|
||||
shutil.move(str(f), str(destino / f.name))
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Fila de jobs — tabela simples, sem Redis nem Celery
|
||||
# --------------------------------------------------------------------------
|
||||
def enfileirar(c, tipo: str, payload: dict) -> None:
|
||||
c.execute("INSERT INTO job(tipo,payload,proxima_em) VALUES (?,?,?)",
|
||||
(tipo, json.dumps(payload), time.time()))
|
||||
|
||||
|
||||
ESPERA = [60, 300, 1800] # 1 min, 5 min, 30 min
|
||||
|
||||
|
||||
def worker() -> None:
|
||||
while True:
|
||||
try:
|
||||
with con() as c:
|
||||
job = c.execute(
|
||||
"SELECT * FROM job WHERE feito=0 AND proxima_em<=? "
|
||||
"ORDER BY id LIMIT 1", (time.time(),)).fetchone()
|
||||
if not job:
|
||||
time.sleep(3)
|
||||
continue
|
||||
|
||||
p = json.loads(job["payload"])
|
||||
try:
|
||||
if job["tipo"] == "tiny_marcador":
|
||||
tiny.marcador(p["pedido"], p["marcador"])
|
||||
elif job["tipo"] == "whats":
|
||||
whats.avisar(p["pedido"], p["tipo"])
|
||||
c.execute("UPDATE job SET feito=1 WHERE id=?", (job["id"],))
|
||||
except Exception as e:
|
||||
n = job["tentativas"] + 1
|
||||
if n > len(ESPERA):
|
||||
c.execute("UPDATE job SET feito=1, erro=? WHERE id=?",
|
||||
(f"desistiu: {e}", job["id"]))
|
||||
# TODO: alertar o TI
|
||||
else:
|
||||
c.execute(
|
||||
"UPDATE job SET tentativas=?, proxima_em=?, erro=? WHERE id=?",
|
||||
(n, time.time() + ESPERA[n - 1], str(e), job["id"]))
|
||||
except Exception:
|
||||
time.sleep(5)
|
||||
|
||||
|
||||
threading.Thread(target=worker, daemon=True).start()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# QR do carimbo aponta para cá
|
||||
# --------------------------------------------------------------------------
|
||||
@app.get("/p/{pedido}")
|
||||
def abrir_card(pedido: str):
|
||||
"""A revisão bipa o QR do filme e cai direto no card."""
|
||||
with con() as c:
|
||||
card = c.execute("SELECT arte_id FROM card WHERE pedido=?", (pedido,)).fetchone()
|
||||
if not card:
|
||||
raise HTTPException(404, "pedido não encontrado no kanban")
|
||||
return RedirectResponse(f"/?card={card['arte_id']}")
|
||||
|
||||
|
||||
@app.get("/api/pedido/{pedido}")
|
||||
def trilha(pedido: str):
|
||||
"""Tempo em cada etapa e quanto do total foi só esperando."""
|
||||
PARADO = {"rec", "tra", "fil", "cor"}
|
||||
with con() as c:
|
||||
movs = c.execute(
|
||||
"SELECT * FROM movimento WHERE pedido=? ORDER BY id", (pedido,)).fetchall()
|
||||
card = c.execute("SELECT * FROM card WHERE pedido=?", (pedido,)).fetchone()
|
||||
if not card:
|
||||
raise HTTPException(404)
|
||||
|
||||
agora = int((datetime.now() - datetime.fromisoformat(card["desde"])).total_seconds())
|
||||
etapas = [{"coluna": m["de"], "nome": INFO[m["de"]][1], "segundos": m["segundos"]}
|
||||
for m in movs]
|
||||
etapas.append({"coluna": card["coluna"], "nome": INFO[card["coluna"]][1],
|
||||
"segundos": agora, "atual": True})
|
||||
|
||||
total = sum(e["segundos"] for e in etapas)
|
||||
esperando = sum(e["segundos"] for e in etapas if e["coluna"] in PARADO)
|
||||
return {"pedido": pedido, "cliente": card["cliente"], "metros": card["metros"],
|
||||
"etapas": etapas, "total_seg": total, "esperando_seg": esperando}
|
||||
|
||||
|
||||
PERIODOS = {"hoje": 1, "7": 7, "30": 30, "mes": 30, "ant": 60}
|
||||
|
||||
|
||||
@app.get("/api/relatorio/impressao")
|
||||
def tempo_impressao(dias: int = 7):
|
||||
"""
|
||||
Tempo médio de impressão e velocidade real por máquina.
|
||||
Sai da coluna 'imp' da tabela movimento, cruzada com os metros do card.
|
||||
É este número que diz se 20 m/h é a velocidade real ou só a de catálogo.
|
||||
"""
|
||||
with con() as c:
|
||||
linhas = c.execute("""
|
||||
SELECT m.maquina, COUNT(*) n,
|
||||
AVG(m.segundos) media_seg,
|
||||
SUM(c.metros) metros,
|
||||
SUM(m.segundos) total_seg
|
||||
FROM movimento m JOIN card c ON c.arte_id = m.arte_id
|
||||
WHERE m.de = 'imp' AND m.saiu_em >= date('now', ?)
|
||||
GROUP BY m.maquina""", (f"-{dias} days",)).fetchall()
|
||||
saida = []
|
||||
for r in linhas:
|
||||
horas = (r["total_seg"] or 0) / 3600
|
||||
saida.append({
|
||||
"maquina": r["maquina"], "pedidos": r["n"],
|
||||
"media_min": round((r["media_seg"] or 0) / 60),
|
||||
"metros": round(r["metros"] or 0, 1),
|
||||
"m_por_hora": round((r["metros"] or 0) / horas, 1) if horas else None,
|
||||
})
|
||||
return saida
|
||||
|
||||
|
||||
# Depósito do Tiny para onde o insumo é transferido antes de imprimir.
|
||||
# A Altus também VENDE insumo, então esse depósito separa o que é consumo
|
||||
# interno do que é revenda. Confirmar o nome exato no cadastro do Tiny.
|
||||
DEPOSITO_IMPRESSAO = "Sala DTF"
|
||||
SKU_FILME = "DTF.FILME.57"
|
||||
|
||||
|
||||
@app.get("/api/relatorio/aproveitamento")
|
||||
def aproveitamento(dias: int = 30):
|
||||
"""
|
||||
Quanto do filme transferido virou metro faturado.
|
||||
|
||||
Compra-se rolo de 100 m e imprime-se cerca de 90, por acerto de máquina,
|
||||
prova de cor, refile e sobra de ponta. Cada ponto percentual vale cerca de
|
||||
R$ 980/mês no volume atual.
|
||||
|
||||
NÃO exige apontamento novo na sala: o consumo já é registrado hoje, quando
|
||||
o insumo é transferido para o depósito de impressão no Tiny. Este endpoint
|
||||
só lê a movimentação desse depósito.
|
||||
"""
|
||||
fat = 0.0
|
||||
with con() as c:
|
||||
fat = c.execute(
|
||||
"SELECT SUM(metros) m FROM card WHERE coluna='fin' AND desde >= date('now', ?)",
|
||||
(f"-{dias} days",)).fetchone()["m"] or 0
|
||||
|
||||
cons = tiny.transferido_para_deposito(
|
||||
sku=SKU_FILME, deposito=DEPOSITO_IMPRESSAO, dias=dias)
|
||||
|
||||
pct = fat / cons * 100 if cons else None
|
||||
return {"metros_faturados": round(fat, 1),
|
||||
"metros_de_filme": round(cons, 1),
|
||||
"fonte": f"transferências para o depósito {DEPOSITO_IMPRESSAO} no Tiny",
|
||||
"aproveitamento_pct": round(pct, 1) if pct else None,
|
||||
"valor_do_ponto_mes": 980}
|
||||
|
||||
|
||||
@app.get("/api/relatorio/etapas")
|
||||
def relatorio(dias: int = 7):
|
||||
"""Tempo médio por etapa. É o número que hoje não existe em lugar nenhum."""
|
||||
with con() as c:
|
||||
linhas = c.execute("""
|
||||
SELECT de, COUNT(*) n, AVG(segundos) media, MAX(segundos) pior
|
||||
FROM movimento
|
||||
WHERE saiu_em >= date('now', ?)
|
||||
GROUP BY de""", (f"-{dias} days",)).fetchall()
|
||||
return [{"coluna": r["de"], "nome": INFO[r["de"]][1], "movimentos": r["n"],
|
||||
"media_min": round(r["media"] / 60), "pior_min": round(r["pior"] / 60)}
|
||||
for r in linhas]
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def index():
|
||||
return FileResponse("static/kanban.html")
|
||||
@@ -1,271 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="pt-BR">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>DTF · sala de impressão</title>
|
||||
<link rel="preconnect" href="https://fonts.googleapis.com">
|
||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@500;700&family=IBM+Plex+Mono:wght@400;500;600&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
:root{--bg:#0B0D10;--card:#15181D;--card2:#1C2026;--linha:#282C34;--tx:#EDEBE6;--fraco:#7C828C;
|
||||
--ciano:#00B8DA;--magenta:#E0357C;--amarelo:#EFB700;--branco:#EDEBE6;--verde:#48B072;
|
||||
--alerta:#E0642A;--roxo:#8E7CE8}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{background:var(--bg);color:var(--tx);font-family:"IBM Plex Mono",ui-monospace,monospace;
|
||||
font-size:14px;line-height:1.45;-webkit-font-smoothing:antialiased;padding:16px 14px 34px}
|
||||
h1,h2{font-family:"Space Grotesk",system-ui,sans-serif;letter-spacing:-.02em}
|
||||
.topo{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap;margin-bottom:6px}
|
||||
h1{font-size:23px;font-weight:700}
|
||||
.sub{color:var(--fraco);font-size:12px}
|
||||
.rel{margin-left:auto;font-family:"Space Grotesk",sans-serif;font-size:19px;font-weight:700;color:var(--ciano)}
|
||||
|
||||
.aviso{background:#152026;border:1px solid #24404A;border-left:3px solid var(--ciano);
|
||||
border-radius:0 8px 8px 0;padding:10px 14px;font-size:12.5px;color:#B9CBD2;margin:10px 0 14px}
|
||||
.aviso b{color:var(--ciano);font-family:"Space Grotesk",sans-serif}
|
||||
|
||||
/* máquinas */
|
||||
.maqs{display:grid;grid-template-columns:repeat(6,1fr);gap:8px;margin-bottom:14px}
|
||||
@media(max-width:900px){.maqs{grid-template-columns:repeat(3,1fr)}}
|
||||
.mq{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 12px;position:relative}
|
||||
.mq.ocup{border-color:var(--alerta)}
|
||||
.mq .luz{width:11px;height:11px;border-radius:50%;position:absolute;top:11px;right:11px;background:var(--verde)}
|
||||
.mq.ocup .luz{background:var(--alerta);box-shadow:0 0 0 3px rgba(224,100,42,.2)}
|
||||
.mq b{font-family:"Space Grotesk",sans-serif;font-size:13px;display:block;margin-bottom:4px}
|
||||
.mq .st{font-size:10.5px;color:var(--fraco);line-height:1.4;min-height:28px}
|
||||
.mq button{margin-top:8px;width:100%;font-family:inherit;font-size:11px;padding:6px;border-radius:6px;
|
||||
border:1px solid var(--linha);background:var(--card2);color:var(--fraco);cursor:pointer}
|
||||
.mq.livre button{background:var(--ciano);border-color:var(--ciano);color:#03181D;font-weight:600}
|
||||
.mq.livre button:hover{filter:brightness(1.1)}
|
||||
.mq.ocup button{opacity:.4;cursor:default}
|
||||
|
||||
/* kpis */
|
||||
.kpis{display:grid;grid-template-columns:repeat(auto-fit,minmax(150px,1fr));gap:9px;margin-bottom:14px}
|
||||
.k{background:var(--card);border:1px solid var(--linha);border-radius:9px;padding:11px 14px;border-left:3px solid var(--c,var(--linha))}
|
||||
.k dt{font-size:10px;letter-spacing:.15em;text-transform:uppercase;color:var(--fraco);margin-bottom:3px}
|
||||
.k dd{font-family:"Space Grotesk",sans-serif;font-size:23px;font-weight:700}
|
||||
.k dd small{font-size:12px;color:var(--fraco);font-weight:400;font-family:"IBM Plex Mono",monospace}
|
||||
|
||||
/* kanban */
|
||||
.kan{display:grid;grid-template-columns:repeat(7,minmax(140px,1fr));gap:8px;overflow-x:auto;padding-bottom:16px}
|
||||
@media(max-width:1200px){.kan{grid-template-columns:repeat(7,168px)}}
|
||||
.col{background:var(--card);border:1px solid var(--linha);border-radius:9px;display:flex;flex-direction:column;min-height:250px}
|
||||
.col.alvo{border-color:var(--ciano);background:#101820}
|
||||
.ch{padding:10px 11px 8px;border-bottom:1px solid var(--linha)}
|
||||
.ch b{font-family:"Space Grotesk",sans-serif;font-size:12px;display:block}
|
||||
.ch b .p{display:inline-block;width:7px;height:7px;border-radius:50%;margin-right:6px;background:var(--cc)}
|
||||
.ch small{font-size:10px;color:var(--fraco)}
|
||||
.cb{padding:8px;display:flex;flex-direction:column;gap:6px;flex:1}
|
||||
.vazio{color:var(--fraco);font-size:10.5px;text-align:center;padding:14px 4px}
|
||||
.cd{background:var(--card2);border:1px solid var(--linha);border-left:3px solid var(--cc);
|
||||
border-radius:5px;padding:8px 9px;cursor:grab;font-size:11px}
|
||||
.cd:active{cursor:grabbing}
|
||||
.cd.arrasta{opacity:.35}
|
||||
.cd b{display:block;font-family:"Space Grotesk",sans-serif;font-size:12.5px}
|
||||
.cd .cli{color:var(--fraco);font-size:10px;margin:1px 0 5px}
|
||||
.cd .l{display:flex;justify-content:space-between;font-size:10px}
|
||||
.cd .l i{font-style:normal;color:var(--fraco)}
|
||||
.cd .mq2{display:inline-block;font-size:9px;padding:1px 5px;border-radius:3px;background:var(--linha);margin-top:4px}
|
||||
.cd.q .l i{color:var(--amarelo)}
|
||||
.cd.r{border-left-color:var(--alerta)}
|
||||
.cd.r .l i{color:var(--alerta)}
|
||||
|
||||
.rod{margin-top:14px;display:flex;gap:9px;align-items:center;flex-wrap:wrap;color:var(--fraco);font-size:11.5px}
|
||||
.rod button{font-family:inherit;font-size:11.5px;padding:7px 12px;border-radius:7px;
|
||||
border:1px solid var(--linha);background:none;color:var(--fraco);cursor:pointer}
|
||||
.rod button:hover{border-color:var(--ciano);color:var(--ciano)}
|
||||
@media(prefers-reduced-motion:reduce){*{transition:none!important}}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="topo">
|
||||
<h1>Kanban da sala de DTF</h1>
|
||||
<span class="sub" id="ctx">carregando…</span>
|
||||
<span class="rel" id="hora">—</span>
|
||||
</div>
|
||||
|
||||
<div class="aviso" id="aviso" style="display:none"></div>
|
||||
|
||||
<div class="maqs" id="maqs"></div>
|
||||
<dl class="kpis" id="kpis"></dl>
|
||||
<div class="kan" id="kan"></div>
|
||||
|
||||
<div class="rod">
|
||||
<button id="atualizar">Atualizar</button>
|
||||
<span id="msg"></span>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const COLS=[
|
||||
{id:'rec', nome:'Arte recebida', cor:'var(--ciano)'},
|
||||
{id:'tra', nome:'Arte tratada', cor:'var(--magenta)'},
|
||||
{id:'cor2',nome:'Prova de cor', cor:'var(--roxo)'},
|
||||
{id:'fil', nome:'Fila', cor:'var(--amarelo)'},
|
||||
{id:'imp', nome:'Imprimindo', cor:'var(--branco)'},
|
||||
{id:'cor', nome:'Correção', cor:'var(--alerta)'},
|
||||
{id:'fin', nome:'Finalizado', cor:'var(--verde)'}
|
||||
];
|
||||
const API=''; // mesma origem do PCP
|
||||
const USUARIO=window.PCP_USUARIO || 'operador';
|
||||
const MAQUINA=new URLSearchParams(location.search).get('maq'); // ?maq=4
|
||||
const PARADO=['rec','tra','cor2','fil','cor'];
|
||||
const H=36e5, M=6e4;
|
||||
let cards=[], maquinas=[], erro=false;
|
||||
|
||||
async function api(rota,opt){
|
||||
const r=await fetch(API+rota,{headers:{'Content-Type':'application/json'},...opt});
|
||||
if(!r.ok){
|
||||
let m='erro '+r.status;
|
||||
try{ m=(await r.json()).detail || m; }catch(e){}
|
||||
throw new Error(m);
|
||||
}
|
||||
return r.status===204? null : r.json();
|
||||
}
|
||||
async function carregar(){
|
||||
try{
|
||||
const d=await api('/api/quadro');
|
||||
cards=[]; maquinas=d.maquinas||[];
|
||||
Object.entries(d.cards||{}).forEach(([col,lista])=>
|
||||
lista.forEach(c=>cards.push({
|
||||
id:String(c.arte_id), pedido:c.pedido, cli:c.cliente,
|
||||
m:c.metros, min:c.minutos_maquina || Math.round(c.metros/20*60),
|
||||
col, desde:Date.parse(c.desde), maq:c.maquina? +String(c.maquina).replace(/\D/g,''):null
|
||||
})));
|
||||
erro=false; esconderAviso();
|
||||
}catch(e){
|
||||
erro=true; mostrarAviso('Sem conexão com o servidor — '+e.message+'. A tela não está atualizando.');
|
||||
}
|
||||
pinta();
|
||||
}
|
||||
function mostrarAviso(t){
|
||||
const a=document.getElementById('aviso');
|
||||
a.style.display='block'; a.innerHTML='<b>Atenção</b> '+t;
|
||||
}
|
||||
function esconderAviso(){ document.getElementById('aviso').style.display='none'; }
|
||||
function salvar(){ /* estado vive no servidor */ }
|
||||
|
||||
async function puxar(n){
|
||||
try{
|
||||
const r=await api('/api/puxar',{method:'POST',
|
||||
body:JSON.stringify({maquina:n, usuario:USUARIO})});
|
||||
msg('Maq '+n+' puxou o #'+r.pedido+' · '+r.minutos+' min · reserva de '+r.reserva_expira_em+' min');
|
||||
}catch(e){ msg(e.message); }
|
||||
carregar();
|
||||
}
|
||||
async function mover(arte_id,para,maq){
|
||||
try{
|
||||
await api('/api/mover',{method:'POST',
|
||||
body:JSON.stringify({arte_id:+arte_id, para, usuario:USUARIO, maquina:maq||null})});
|
||||
}catch(e){ msg(e.message); }
|
||||
carregar();
|
||||
}
|
||||
async function devolver(arte_id){
|
||||
try{
|
||||
await api('/api/devolver',{method:'POST',
|
||||
body:JSON.stringify({arte_id:+arte_id, usuario:USUARIO})});
|
||||
msg('devolvido ao topo da fila');
|
||||
}catch(e){ msg(e.message); }
|
||||
carregar();
|
||||
}
|
||||
|
||||
function pintaMaqs(){
|
||||
const el=document.getElementById('maqs'); el.innerHTML='';
|
||||
for(let n=1;n<=6;n++){
|
||||
const info=maquinas.find(x=>x.n===n) || {};
|
||||
const job=cards.find(c=>c.col==='imp' && c.maq===n) ||
|
||||
(info.ocupada? {pedido:info.pedido,m:info.metros,desde:Date.now()-(info.rodando_seg||0)*1000}:null);
|
||||
if(MAQUINA && String(n)!==MAQUINA) continue;
|
||||
const d=document.createElement('div');
|
||||
d.className='mq '+(job?'ocup':'livre');
|
||||
d.innerHTML='<span class="luz"></span><b>Maq '+n+'</b>'+
|
||||
'<div class="st">'+(job
|
||||
? '#'+job.pedido+' · '+job.m.toFixed(1).replace('.',',')+' m<br>rodando há '+dur(Date.now()-job.desde)
|
||||
: 'livre<br> ')+'</div>'+
|
||||
'<button '+(job?'disabled':'')+'>'+(job?'ocupada':'puxar próximo')+'</button>';
|
||||
if(!job) d.querySelector('button').addEventListener('click',()=>puxar(n));
|
||||
el.appendChild(d);
|
||||
}
|
||||
}
|
||||
function puxar(n){
|
||||
const fila=cards.filter(c=>c.col==='fil').sort((a,b)=>a.desde-b.desde);
|
||||
if(!fila.length) return msg('a fila está vazia');
|
||||
const c=fila[0];
|
||||
c.col='imp'; c.maq=n; c.desde=Date.now();
|
||||
salvar(); pinta();
|
||||
msg('Maq '+n+' puxou o #'+c.pedido+' — o mais antigo da fila');
|
||||
}
|
||||
function pintaKpis(){
|
||||
const fila=cards.filter(c=>['rec','tra','cor2','fil'].includes(c.col));
|
||||
const mf=fila.reduce((s,c)=>s+c.m,0), mn=fila.reduce((s,c)=>s+c.min,0);
|
||||
const feito=cards.filter(c=>c.col==='fin').reduce((s,c)=>s+c.m,0);
|
||||
const livres=[1,2,3,4,5,6].filter(n=>!cards.some(c=>c.col==='imp'&&c.maq===n)).length;
|
||||
const cap=20*6*4.5; // 6 máquinas, 20 m/h, 4h30 restantes
|
||||
const risco=cards.filter(c=>PARADO.includes(c.col)&&Date.now()-c.desde>3*H).length;
|
||||
const k=[
|
||||
['Na fila', mf.toFixed(1).replace('.',',')+' <small>m</small>','var(--amarelo)'],
|
||||
['Minutos de máquina', mn+' <small>min</small>','var(--amarelo)'],
|
||||
['Capacidade até 20h', Math.round(cap)+' <small>m</small>', mf>cap?'var(--alerta)':'var(--verde)'],
|
||||
['Máquinas livres',livres+' <small>de 6</small>','var(--ciano)'],
|
||||
['Impresso hoje', feito.toFixed(1).replace('.',',')+' <small>m</small>','var(--verde)'],
|
||||
['Parados +3h', risco+' <small>pedido'+(risco===1?'':'s')+'</small>', risco?'var(--alerta)':'var(--verde)']
|
||||
];
|
||||
document.getElementById('kpis').innerHTML=k.map(x=>
|
||||
'<div class="k" style="--c:'+x[2]+'"><dt>'+x[0]+'</dt><dd>'+x[1]+'</dd></div>').join('');
|
||||
}
|
||||
function pinta(){
|
||||
pintaMaqs(); pintaKpis();
|
||||
const agora=Date.now(), el=document.getElementById('kan'); el.innerHTML='';
|
||||
COLS.forEach(col=>{
|
||||
const its=cards.filter(c=>c.col===col.id).sort((a,b)=>a.desde-b.desde);
|
||||
const mt=its.reduce((s,c)=>s+c.m,0);
|
||||
const d=document.createElement('div');
|
||||
d.className='col'; d.dataset.col=col.id; d.style.setProperty('--cc',col.cor);
|
||||
d.innerHTML='<div class="ch"><b><span class="p"></span>'+col.nome+'</b>'+
|
||||
'<small>'+its.length+' · '+mt.toFixed(1).replace('.',',')+' m</small></div><div class="cb"></div>';
|
||||
const cb=d.querySelector('.cb');
|
||||
if(!its.length) cb.innerHTML='<div class="vazio">—</div>';
|
||||
its.forEach(c=>{
|
||||
const p=agora-c.desde, fin=col.id==='fin';
|
||||
const cl=fin?'':(p>3*H?' r':(p>1*H?' q':''));
|
||||
const e=document.createElement('div');
|
||||
e.className='cd'+cl; e.draggable=true; e.dataset.id=c.id;
|
||||
e.innerHTML='<b>#'+c.pedido+'</b><div class="cli">'+c.cli+'</div>'+
|
||||
'<div class="l"><i>'+(fin?'concluído':'há '+dur(p))+'</i>'+
|
||||
'<span>'+c.m.toFixed(1).replace('.',',')+' m · '+c.min+' min</span></div>'+
|
||||
(c.maq?'<span class="mq2">Maq '+c.maq+'</span>':'');
|
||||
e.addEventListener('dragstart',ev=>{ev.dataTransfer.setData('text/plain',c.id);e.classList.add('arrasta')});
|
||||
e.addEventListener('dragend',()=>e.classList.remove('arrasta'));
|
||||
cb.appendChild(e);
|
||||
});
|
||||
d.addEventListener('dragover',ev=>{ev.preventDefault();d.classList.add('alvo')});
|
||||
d.addEventListener('dragleave',()=>d.classList.remove('alvo'));
|
||||
d.addEventListener('drop',ev=>{
|
||||
ev.preventDefault(); d.classList.remove('alvo');
|
||||
const c=cards.find(x=>x.id===ev.dataTransfer.getData('text/plain'));
|
||||
if(!c||c.col===col.id) return;
|
||||
if(c.col==='imp'&&col.id==='fil') return devolver(c.id);
|
||||
let maq=null;
|
||||
if(col.id==='imp'){
|
||||
maq=[1,2,3,4,5,6].find(n=>!cards.some(x=>x.col==='imp'&&x.maq===n));
|
||||
if(!maq) return msg('todas as máquinas estão ocupadas');
|
||||
}
|
||||
mover(c.id,col.id,maq);
|
||||
});
|
||||
el.appendChild(d);
|
||||
});
|
||||
}
|
||||
document.getElementById('atualizar').addEventListener('click',carregar);
|
||||
document.getElementById('ctx').textContent =
|
||||
MAQUINA ? 'Maq '+MAQUINA+' · '+USUARIO : 'visão geral · '+USUARIO;
|
||||
function tic(){
|
||||
document.getElementById('hora').textContent=
|
||||
new Date().toLocaleTimeString('pt-BR',{hour:'2-digit',minute:'2-digit'});
|
||||
}
|
||||
tic(); setInterval(tic,30000);
|
||||
setInterval(pinta,30000); // relógio dos cards
|
||||
setInterval(carregar,15000); // busca o estado no servidor
|
||||
carregar();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,89 +0,0 @@
|
||||
"""
|
||||
Cliente da API do Tiny (Olist) — v3, OAuth2 client credentials.
|
||||
|
||||
ATENÇÃO Wagner: confirmar na documentação oficial antes de subir:
|
||||
- o endpoint exato de marcadores do pedido
|
||||
- o limite de requisições por minuto (dimensiona o worker)
|
||||
- se o refresh token expira e com que frequência
|
||||
Este módulo isola a API: se o endpoint mudar, muda só aqui.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = os.environ.get("TINY_BASE", "https://api.tiny.com.br/public-api/v3")
|
||||
CLIENT_ID = os.environ["TINY_CLIENT_ID"]
|
||||
CLIENT_SECRET = os.environ["TINY_CLIENT_SECRET"]
|
||||
TOKEN_URL = os.environ["TINY_TOKEN_URL"]
|
||||
|
||||
_token: dict = {"valor": None, "expira": 0.0}
|
||||
|
||||
|
||||
def _acesso() -> str:
|
||||
if _token["valor"] and time.time() < _token["expira"] - 60:
|
||||
return _token["valor"]
|
||||
r = httpx.post(TOKEN_URL, data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
}, timeout=20)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
_token["valor"] = d["access_token"]
|
||||
_token["expira"] = time.time() + d.get("expires_in", 3600)
|
||||
return _token["valor"]
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"Authorization": f"Bearer {_acesso()}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def buscar_pedido(numero: str) -> dict:
|
||||
r = httpx.get(f"{BASE}/pedidos", params={"numero": numero},
|
||||
headers=_headers(), timeout=20)
|
||||
r.raise_for_status()
|
||||
itens = r.json().get("itens") or []
|
||||
if not itens:
|
||||
raise LookupError(f"pedido {numero} não encontrado no Tiny")
|
||||
return itens[0]
|
||||
|
||||
|
||||
def transferido_para_deposito(sku: str, deposito: str, dias: int) -> float:
|
||||
"""
|
||||
Soma o que foi transferido para o depósito de impressão no período.
|
||||
|
||||
A Altus já faz essa transferência hoje, porque também revende insumo — o
|
||||
depósito separa consumo interno de revenda. Por isso o aproveitamento sai
|
||||
de graça: nenhum apontamento novo na sala.
|
||||
|
||||
ATENÇÃO Wagner: confirmar o endpoint de movimentações de estoque por
|
||||
depósito na API v3 e o nome exato do depósito no cadastro.
|
||||
"""
|
||||
r = httpx.get(
|
||||
f"{BASE}/estoque/movimentacoes",
|
||||
params={"codigo": sku, "deposito": deposito, "dias": dias, "tipo": "E"},
|
||||
headers=_headers(), timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return sum(float(m.get("quantidade", 0)) for m in r.json().get("itens", []))
|
||||
|
||||
|
||||
def marcador(numero: str, marcador: str) -> None:
|
||||
"""
|
||||
Troca o marcador do pedido. Substitui os marcadores de etapa do DTF,
|
||||
preservando marcadores de outra natureza (multiempresa, VALE, Troca...).
|
||||
"""
|
||||
pedido = buscar_pedido(numero)
|
||||
atuais = [m["descricao"] for m in pedido.get("marcadores", [])]
|
||||
# só três marcadores de etapa vivem no Tiny; o resto do fluxo é do kanban
|
||||
ETAPAS = {"DTF-RECEBIDA", "DTF-PRODUCAO", "CORRECAO DTF", "DTF-PRONTO"}
|
||||
mantem = [m for m in atuais if m not in ETAPAS]
|
||||
novos = mantem + [marcador]
|
||||
|
||||
r = httpx.put(f"{BASE}/pedidos/{pedido['id']}/marcadores",
|
||||
json={"marcadores": [{"descricao": m} for m in novos]},
|
||||
headers=_headers(), timeout=20)
|
||||
r.raise_for_status()
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
Envio de WhatsApp. Três avisos ao cliente, não sete:
|
||||
aprovada · em produção · finalizada (+ correção, a única que pede resposta)
|
||||
|
||||
Provedor definido por WHATS_PROVEDOR = meta | zapi
|
||||
A API oficial da Meta exige template aprovado para mensagem iniciada por nós.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
PROVEDOR = os.environ.get("WHATS_PROVEDOR", "meta")
|
||||
TOKEN = os.environ.get("WHATS_TOKEN", "")
|
||||
NUMERO_ID = os.environ.get("WHATS_NUMERO_ID", "")
|
||||
ZAPI_URL = os.environ.get("ZAPI_URL", "")
|
||||
|
||||
TEXTOS = {
|
||||
"prova_cor": ("Antes de imprimir o pedido {pedido} inteiro, fizemos uma amostra "
|
||||
"de cor. Confira a foto e responda APROVO para seguirmos. A cor no "
|
||||
"filme pode variar em relação ao seu monitor."),
|
||||
"producao": "Seu pedido {pedido} entrou em produção. Avisamos quando ficar pronto.",
|
||||
"concluido": "Pedido {pedido} finalizado e pronto para retirada ou envio.",
|
||||
"correcao": "Precisamos de um ajuste na arte do pedido {pedido}: {motivo}",
|
||||
}
|
||||
|
||||
|
||||
def _enviar(telefone: str, texto: str) -> None:
|
||||
if PROVEDOR == "zapi":
|
||||
httpx.post(ZAPI_URL, json={"phone": telefone, "message": texto}, timeout=20)
|
||||
return
|
||||
httpx.post(
|
||||
f"https://graph.facebook.com/v20.0/{NUMERO_ID}/messages",
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
json={"messaging_product": "whatsapp", "to": telefone,
|
||||
"type": "text", "text": {"body": texto}},
|
||||
timeout=20,
|
||||
).raise_for_status()
|
||||
|
||||
|
||||
def enviar_link(telefone: str, pedido: str, link: str) -> None:
|
||||
_enviar(telefone,
|
||||
f"Pedido {pedido} confirmado. Envie sua arte por aqui: {link}\n"
|
||||
"O link vale por 7 dias. Gabarito 57 × 97 cm, PNG 300 DPI com fundo "
|
||||
"transparente.")
|
||||
|
||||
|
||||
def enviar_aprovacao(pedido, r) -> None:
|
||||
n = len(r.partes)
|
||||
_enviar(pedido.telefone if hasattr(pedido, "telefone") else "",
|
||||
f"Arte do pedido {pedido.numero_tiny} aprovada. "
|
||||
f"{r.metros_totais:.2f} m em {n} arquivo{'s' if n > 1 else ''}. "
|
||||
"Entrou na fila de impressão.".replace(".", ",", 1))
|
||||
|
||||
|
||||
def enviar_recusa(pedido, motivo: str) -> None:
|
||||
_enviar(getattr(pedido, "telefone", ""),
|
||||
f"A arte do pedido {pedido.numero_tiny} precisa de ajuste: {motivo}\n"
|
||||
"O prazo só começa a contar depois que a arte for aprovada.")
|
||||
|
||||
|
||||
def avisar(pedido: str, tipo: str, motivo: str = "") -> None:
|
||||
# o telefone vem do cadastro do pedido no Tiny
|
||||
import tiny
|
||||
p = tiny.buscar_pedido(pedido)
|
||||
telefone = (p.get("cliente") or {}).get("fone", "")
|
||||
_enviar(telefone, TEXTOS[tipo].format(pedido=pedido, motivo=motivo))
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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)')
|
||||
|
||||
@@ -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
77
local/static/site-cart.js
Normal 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
108
local/static/site-config.js
Normal 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
173
local/static/site-flow.js
Normal 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();
|
||||
|
||||
95
local/static/site-modes.js
Normal file
95
local/static/site-modes.js
Normal 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'});
|
||||
});
|
||||
|
||||
353
local/static/site-packing.js
Normal file
353
local/static/site-packing.js
Normal 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
400
local/static/site-pdf.js
Normal 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>':'');
|
||||
}
|
||||
|
||||
186
local/static/site-quality.js
Normal file
186
local/static/site-quality.js
Normal 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
107
local/static/site-sheet.js
Normal 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
112
local/static/site-upload.js
Normal 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=>({'&':'&','<':'<','>':'>','"':'"',"'":'''}[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
|
||||
@@ -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};'
|
||||
|
||||
322
portal/main.py
322
portal/main.py
@@ -1,322 +0,0 @@
|
||||
"""
|
||||
Portal de recebimento de arte — roda na nuvem, aberto 24 horas.
|
||||
|
||||
Fluxo:
|
||||
0. Tiny avisa por webhook que nasceu um pedido de DTF
|
||||
1. portal cria token e manda o link no WhatsApp
|
||||
2. cliente abre o link e pede uma URL pré-assinada
|
||||
3. o navegador envia o arquivo DIRETO para o storage (não passa por aqui)
|
||||
4. cliente avisa que terminou -> robô valida, repete, fatia e carimba
|
||||
5. o agente da fábrica busca o que foi aprovado
|
||||
|
||||
Rodar:
|
||||
uvicorn main:app --host 0.0.0.0 --port 8000
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
import boto3
|
||||
from fastapi import BackgroundTasks, Depends, FastAPI, Header, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from sqlmodel import Field, Session, SQLModel, create_engine, select
|
||||
|
||||
import preflight
|
||||
import tiny
|
||||
import whats
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
DB = os.environ["DATABASE_URL"]
|
||||
BUCKET = os.environ["S3_BUCKET"]
|
||||
BASE_PORTAL = os.environ["BASE_PORTAL"] # https://arte.dropstaratacado.com.br
|
||||
BASE_KANBAN = os.environ["BASE_KANBAN"] # http://servidor:8080
|
||||
TOKEN_TINY = os.environ["WEBHOOK_TOKEN"] # segredo combinado com o Tiny
|
||||
TOKEN_AGENTE = os.environ["AGENTE_TOKEN"]
|
||||
VALIDADE_TOKEN_DIAS = 7
|
||||
MAX_GB = 5 # limite do que os PCs da sala aguentam abrir
|
||||
MAX_ARQUIVOS = 10
|
||||
|
||||
engine = create_engine(DB)
|
||||
s3 = boto3.client("s3", endpoint_url=os.environ["S3_ENDPOINT"])
|
||||
app = FastAPI(title="Portal de arte DTF")
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Tabelas
|
||||
# --------------------------------------------------------------------------
|
||||
class Pedido(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
numero_tiny: str = Field(index=True, unique=True)
|
||||
cliente: str
|
||||
metros: float # metros comprados por arte
|
||||
token: str = Field(index=True, unique=True)
|
||||
token_expira: datetime
|
||||
criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Arte(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
pedido_id: int = Field(foreign_key="pedido.id", index=True)
|
||||
arquivo: str
|
||||
bytes: int = 0
|
||||
repeticoes: int = 1
|
||||
status: str = "recebida" # recebida | aprovada | recusada
|
||||
motivo: str = ""
|
||||
avisos: str = ""
|
||||
dpi_efetivo: float = 0
|
||||
largura_cm: float = 0
|
||||
metros_totais: float = 0
|
||||
baixada: bool = False
|
||||
criado_em: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
|
||||
|
||||
|
||||
class Parte(SQLModel, table=True):
|
||||
id: int | None = Field(default=None, primary_key=True)
|
||||
arte_id: int = Field(foreign_key="arte.id", index=True)
|
||||
ordem: int
|
||||
metros: float
|
||||
arquivo: str
|
||||
carimbada: bool = False
|
||||
|
||||
|
||||
SQLModel.metadata.create_all(engine)
|
||||
|
||||
|
||||
def sessao():
|
||||
with Session(engine) as s:
|
||||
yield s
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 0. Webhook do Tiny — pedido novo
|
||||
# --------------------------------------------------------------------------
|
||||
class PedidoTiny(BaseModel):
|
||||
numero: str
|
||||
cliente: str
|
||||
telefone: str
|
||||
metros: float
|
||||
|
||||
|
||||
@app.post("/webhook/tiny")
|
||||
def pedido_novo(
|
||||
p: PedidoTiny,
|
||||
background: BackgroundTasks,
|
||||
x_token: str = Header(default=""),
|
||||
s: Session = Depends(sessao),
|
||||
):
|
||||
if x_token != TOKEN_TINY:
|
||||
raise HTTPException(401, "token inválido")
|
||||
|
||||
existente = s.exec(select(Pedido).where(Pedido.numero_tiny == p.numero)).first()
|
||||
if existente:
|
||||
return {"ok": True, "ja_existia": True}
|
||||
|
||||
pedido = Pedido(
|
||||
numero_tiny=p.numero,
|
||||
cliente=p.cliente,
|
||||
metros=p.metros,
|
||||
token=uuid.uuid4().hex,
|
||||
token_expira=datetime.now(timezone.utc) + timedelta(days=VALIDADE_TOKEN_DIAS),
|
||||
)
|
||||
s.add(pedido)
|
||||
s.commit()
|
||||
s.refresh(pedido)
|
||||
|
||||
link = f"{BASE_PORTAL}/arte/{pedido.token}"
|
||||
background.add_task(whats.enviar_link, p.telefone, p.numero, link)
|
||||
return {"ok": True, "link": link}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1 e 2. Página e URL pré-assinada
|
||||
# --------------------------------------------------------------------------
|
||||
def _pedido_por_token(token: str, s: Session) -> Pedido:
|
||||
p = s.exec(select(Pedido).where(Pedido.token == token)).first()
|
||||
if not p:
|
||||
raise HTTPException(404, "link inválido")
|
||||
if p.token_expira < datetime.now(timezone.utc):
|
||||
raise HTTPException(410, "link expirado — fale com o atendimento")
|
||||
return p
|
||||
|
||||
|
||||
@app.get("/arte/{token}", response_class=HTMLResponse)
|
||||
def pagina(token: str, s: Session = Depends(sessao)):
|
||||
_pedido_por_token(token, s)
|
||||
return open("static/portal.html", encoding="utf-8").read()
|
||||
|
||||
|
||||
@app.get("/api/arte/{token}")
|
||||
def dados(token: str, s: Session = Depends(sessao)):
|
||||
p = _pedido_por_token(token, s)
|
||||
return {
|
||||
"pedido": p.numero_tiny,
|
||||
"cliente": p.cliente,
|
||||
"metros": p.metros,
|
||||
"area_util_cm": preflight.AREA_UTIL_CM,
|
||||
"limite_arquivo_m": preflight.LIMITE_ARQUIVO_M,
|
||||
"max_gb": MAX_GB,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/arte/{token}/url")
|
||||
def url_upload(token: str, nome: str, s: Session = Depends(sessao)):
|
||||
p = _pedido_por_token(token, s)
|
||||
n = len(s.exec(select(Arte).where(Arte.pedido_id == p.id)).all())
|
||||
if n >= MAX_ARQUIVOS:
|
||||
raise HTTPException(400, f"máximo de {MAX_ARQUIVOS} arquivos por pedido")
|
||||
|
||||
chave = f"{p.numero_tiny}/{uuid.uuid4().hex}_{nome}"
|
||||
url = s3.generate_presigned_url(
|
||||
"put_object",
|
||||
Params={"Bucket": BUCKET, "Key": chave},
|
||||
ExpiresIn=3600,
|
||||
)
|
||||
return {"url": url, "chave": chave}
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Terminou o upload -> roda o pré-flight
|
||||
# --------------------------------------------------------------------------
|
||||
class Pronto(BaseModel):
|
||||
chave: str
|
||||
repeticoes: int = 1
|
||||
|
||||
|
||||
@app.post("/api/arte/{token}/pronto")
|
||||
def pronto(
|
||||
token: str,
|
||||
body: Pronto,
|
||||
background: BackgroundTasks,
|
||||
s: Session = Depends(sessao),
|
||||
):
|
||||
p = _pedido_por_token(token, s)
|
||||
arte = Arte(pedido_id=p.id, arquivo=body.chave, repeticoes=max(1, body.repeticoes))
|
||||
s.add(arte)
|
||||
s.commit()
|
||||
s.refresh(arte)
|
||||
background.add_task(processar, arte.id)
|
||||
return {"arte_id": arte.id, "status": "processando"}
|
||||
|
||||
|
||||
def processar(arte_id: int):
|
||||
"""Baixa do storage, valida, repete, fatia, carimba e devolve as partes."""
|
||||
with Session(engine) as s:
|
||||
arte = s.get(Arte, arte_id)
|
||||
pedido = s.get(Pedido, arte.pedido_id)
|
||||
|
||||
local = f"/tmp/{arte.id}.png"
|
||||
s3.download_file(BUCKET, arte.arquivo, local)
|
||||
arte.bytes = os.path.getsize(local)
|
||||
|
||||
r = preflight.processar(
|
||||
caminho=local,
|
||||
cm_comprados=pedido.metros * 100,
|
||||
repeticoes=arte.repeticoes,
|
||||
pedido=pedido.numero_tiny,
|
||||
base_kanban=BASE_KANBAN,
|
||||
pasta_saida="/tmp",
|
||||
)
|
||||
|
||||
arte.dpi_efetivo = r.dpi_efetivo
|
||||
arte.largura_cm = r.largura_cm
|
||||
arte.metros_totais = r.metros_totais
|
||||
arte.avisos = " | ".join(r.avisos)
|
||||
|
||||
if not r.aprovado:
|
||||
arte.status, arte.motivo = "recusada", r.motivo
|
||||
s.add(arte)
|
||||
s.commit()
|
||||
whats.enviar_recusa(pedido, arte.motivo)
|
||||
return
|
||||
|
||||
for i, caminho in enumerate(r.partes, start=1):
|
||||
chave = f"{pedido.numero_tiny}/partes/{os.path.basename(caminho)}"
|
||||
s3.upload_file(caminho, BUCKET, chave)
|
||||
s.add(Parte(
|
||||
arte_id=arte.id,
|
||||
ordem=i,
|
||||
metros=min(preflight.LIMITE_ARQUIVO_M,
|
||||
r.metros_totais - preflight.LIMITE_ARQUIVO_M * (i - 1)),
|
||||
arquivo=chave,
|
||||
carimbada=(i == len(r.partes)),
|
||||
))
|
||||
|
||||
arte.status = "aprovada"
|
||||
s.add(arte)
|
||||
s.commit()
|
||||
|
||||
tiny.marcador(pedido.numero_tiny, "DTF-RECEBIDA")
|
||||
whats.enviar_aprovacao(pedido, r)
|
||||
avisar_agente()
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. O agente busca o que foi aprovado
|
||||
# --------------------------------------------------------------------------
|
||||
@app.get("/api/artes")
|
||||
def para_baixar(x_token: str = Header(default=""), s: Session = Depends(sessao)):
|
||||
if x_token != TOKEN_AGENTE:
|
||||
raise HTTPException(401, "token inválido")
|
||||
|
||||
artes = s.exec(
|
||||
select(Arte).where(Arte.status == "aprovada", Arte.baixada == False) # noqa: E712
|
||||
).all()
|
||||
saida = []
|
||||
for a in artes:
|
||||
p = s.get(Pedido, a.pedido_id)
|
||||
partes = s.exec(select(Parte).where(Parte.arte_id == a.id)).all()
|
||||
saida.append({
|
||||
"arte_id": a.id,
|
||||
"pedido": p.numero_tiny,
|
||||
"cliente": p.cliente,
|
||||
"metros": a.metros_totais,
|
||||
"partes": [
|
||||
{
|
||||
"ordem": pt.ordem,
|
||||
"metros": pt.metros,
|
||||
"url": s3.generate_presigned_url(
|
||||
"get_object",
|
||||
Params={"Bucket": BUCKET, "Key": pt.arquivo},
|
||||
ExpiresIn=3600,
|
||||
),
|
||||
"nome": os.path.basename(pt.arquivo),
|
||||
}
|
||||
for pt in sorted(partes, key=lambda x: x.ordem)
|
||||
],
|
||||
})
|
||||
return saida
|
||||
|
||||
|
||||
@app.post("/api/artes/{arte_id}/baixada")
|
||||
def marcar_baixada(
|
||||
arte_id: int, x_token: str = Header(default=""), s: Session = Depends(sessao)
|
||||
):
|
||||
if x_token != TOKEN_AGENTE:
|
||||
raise HTTPException(401, "token inválido")
|
||||
a = s.get(Arte, arte_id)
|
||||
a.baixada = True
|
||||
s.add(a)
|
||||
s.commit()
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
def avisar_agente():
|
||||
"""Webhook para o agente na fábrica. O polling de 60s é a rede de segurança."""
|
||||
import httpx
|
||||
|
||||
url = os.environ.get("AGENTE_WEBHOOK")
|
||||
if not url:
|
||||
return
|
||||
try:
|
||||
httpx.post(url, timeout=5, headers={"x-token": TOKEN_AGENTE})
|
||||
except Exception:
|
||||
pass # o polling pega
|
||||
|
||||
|
||||
@app.get("/saude")
|
||||
def saude():
|
||||
return {"ok": True, "em": datetime.now(timezone.utc).isoformat()}
|
||||
@@ -1,415 +0,0 @@
|
||||
"""
|
||||
Pré-flight do DTF: valida a arte, repete, fatia e carimba.
|
||||
|
||||
Regras acordadas com Marcus em 29/08/2026:
|
||||
- unidade de venda: 57 x 100 cm
|
||||
- faixa reservada no rodapé: 30 mm -> área útil 57 x 97 cm
|
||||
- carimbo: QR de 20 mm + texto na mesma altura, só canal preto
|
||||
- limite por arquivo gerado: 15 metros
|
||||
- o carimbo vai apenas na ÚLTIMA parte do pedido
|
||||
|
||||
Dependências: pyvips, qrcode, pillow
|
||||
apt install libvips-tools
|
||||
pip install pyvips qrcode pillow
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
|
||||
import pyvips
|
||||
import qrcode
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Constantes do processo
|
||||
# --------------------------------------------------------------------------
|
||||
LARGURA_MAX_CM = 57.0
|
||||
FAIXA_RODAPE_MM = 30.0 # reservada para o carimbo
|
||||
ALTURA_UNIDADE_CM = 100.0
|
||||
AREA_UTIL_CM = ALTURA_UNIDADE_CM - FAIXA_RODAPE_MM / 10 # 97 cm
|
||||
|
||||
DPI_MINIMO = 150 # abaixo disso recusa
|
||||
DPI_IDEAL = 300 # entre 150 e 300 aceita com aviso
|
||||
LIMITE_ARQUIVO_M = 20.0 # nenhum arquivo gerado passa disso
|
||||
|
||||
QR_MM = 20.0 # leitura por celular
|
||||
ZONA_LIMPA_MM = 3.0 # margem branca ao redor do QR
|
||||
|
||||
FORMATOS_OK = {".png", ".tif", ".tiff", ".psd", ".psb", ".pdf", ".ai", ".svg", ".eps"}
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Normalização — o cliente manda o que quiser, o designer recebe padronizado
|
||||
# --------------------------------------------------------------------------
|
||||
# Não existe "formato certo" único: normalizar tudo para um só jogaria fora o
|
||||
# melhor de cada tipo. A regra é por NATUREZA do conteúdo.
|
||||
#
|
||||
# vetor -> PDF, mantendo vetor (rasterizar aqui é perder qualidade)
|
||||
# pixel -> TIF com transparência (é o que a sala já usa)
|
||||
#
|
||||
# Isso resolve o vai-e-volta que os designers relatam hoje: abrir PDF no Corel
|
||||
# para não rasterizar, exportar, abrir no Photoshop, e esbarrar no limite de
|
||||
# 5 metros do PSD.
|
||||
#
|
||||
# O limite de 5 m NÃO é do Photoshop: é do formato PSD, que trava em 30.000 px
|
||||
# por dimensão — a 150 DPI dá 5,08 m. PSB vai a 300.000 px, ou 50 metros.
|
||||
# Em PDF vetorial não há esse limite; em TIF o teto é 4 GB por arquivo.
|
||||
VETORIAIS = {".pdf", ".ai", ".svg", ".eps"}
|
||||
RASTER = {".png", ".tif", ".tiff", ".psd", ".psb"}
|
||||
FORMATOS_RECUSA = {".jpg", ".jpeg", ".gif", ".bmp", ".webp"}
|
||||
# CDR entra com etiqueta "conferir": pula o pré-flight e vai direto ao designer.
|
||||
# A licença do CorelDRAW é de estação, não cobre servidor processando arquivo.
|
||||
FORMATOS_SEM_VALIDACAO = {".cdr"}
|
||||
|
||||
CM_POR_POLEGADA = 2.54
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Resultado
|
||||
# --------------------------------------------------------------------------
|
||||
@dataclass
|
||||
class Resultado:
|
||||
aprovado: bool
|
||||
motivo: str = "" # mensagem ao cliente quando recusado
|
||||
avisos: list[str] = field(default_factory=list)
|
||||
dpi_efetivo: float = 0.0
|
||||
largura_cm: float = 0.0
|
||||
altura_cm: float = 0.0
|
||||
metros_totais: float = 0.0
|
||||
conferir_manual: bool = False # CDR: entra sem validar
|
||||
natureza: str = "" # vetor | raster | conferir
|
||||
partes: list[str] = field(default_factory=list) # caminhos gerados
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1. Validação
|
||||
# --------------------------------------------------------------------------
|
||||
def validar(caminho: str, cm_comprados: float) -> Resultado:
|
||||
"""
|
||||
cm_comprados = altura em centímetros que o cliente pagou por UMA arte.
|
||||
Ex.: comprou 1 metro -> 100.
|
||||
"""
|
||||
r = Resultado(aprovado=False)
|
||||
ext = os.path.splitext(caminho)[1].lower()
|
||||
|
||||
if ext in FORMATOS_SEM_VALIDACAO:
|
||||
r.aprovado = True
|
||||
r.conferir_manual = True
|
||||
r.avisos.append("CDR não passa pelo pré-flight — vai direto para o designer.")
|
||||
return r
|
||||
|
||||
if ext in FORMATOS_RECUSA:
|
||||
r.motivo = (
|
||||
f"Arquivo {ext.upper().lstrip('.')} não guarda transparência. "
|
||||
"Envie PNG ou TIFF com fundo transparente."
|
||||
)
|
||||
return r
|
||||
|
||||
if ext not in FORMATOS_OK:
|
||||
r.motivo = "Formato não aceito. Envie PNG ou TIFF com fundo transparente."
|
||||
return r
|
||||
|
||||
try:
|
||||
# access sequential: lê em streaming, não carrega 200 MB na memória
|
||||
img = pyvips.Image.new_from_file(caminho, access="sequential")
|
||||
except Exception:
|
||||
r.motivo = "Não conseguimos abrir o arquivo. Ele pode estar corrompido."
|
||||
return r
|
||||
|
||||
# --- canal alfa ---
|
||||
if img.bands < 4 or not img.hasalpha():
|
||||
r.motivo = (
|
||||
"A arte está sem fundo transparente. Todo fundo não removido "
|
||||
"sai impresso em branco na estampa."
|
||||
)
|
||||
return r
|
||||
|
||||
# --- DPI efetivo: a regra que mais pega arquivo ruim ---
|
||||
# O DPI gravado no cabeçalho mente com frequência (o cliente escala a
|
||||
# imagem e o programa mantém "300"). O que vale é pixel dividido pela
|
||||
# medida que ele comprou.
|
||||
area_util_cm = cm_comprados - FAIXA_RODAPE_MM / 10
|
||||
r.dpi_efetivo = img.height / (area_util_cm / CM_POR_POLEGADA)
|
||||
r.altura_cm = area_util_cm
|
||||
r.largura_cm = img.width / r.dpi_efetivo * CM_POR_POLEGADA
|
||||
|
||||
if r.dpi_efetivo < DPI_MINIMO:
|
||||
r.motivo = (
|
||||
f"A resolução real da arte é de {r.dpi_efetivo:.0f} DPI no tamanho "
|
||||
f"que você comprou. Precisamos de pelo menos {DPI_MINIMO} DPI — "
|
||||
"o ideal é 300. Reenvie em maior resolução."
|
||||
)
|
||||
return r
|
||||
|
||||
if r.dpi_efetivo < DPI_IDEAL:
|
||||
r.avisos.append(
|
||||
f"Resolução de {r.dpi_efetivo:.0f} DPI. Funciona, mas detalhes "
|
||||
"finos podem perder definição."
|
||||
)
|
||||
|
||||
# --- largura física ---
|
||||
if r.largura_cm > LARGURA_MAX_CM + 0.2: # 2 mm de tolerância
|
||||
r.motivo = (
|
||||
f"A arte tem {r.largura_cm:.1f} cm de largura. O máximo é "
|
||||
f"{LARGURA_MAX_CM:.0f} cm. Use o gabarito 57 × 97 cm."
|
||||
)
|
||||
return r
|
||||
|
||||
# --- bordas com alfa parcial (sombras suaves) ---
|
||||
alfa = img[img.bands - 1]
|
||||
parcial = ((alfa > 10) & (alfa < 245)).avg() / 255.0
|
||||
if parcial > 0.15:
|
||||
r.avisos.append(
|
||||
"A arte tem muitas bordas suaves. O branco de apoio pode sair "
|
||||
"irregular nessas áreas."
|
||||
)
|
||||
|
||||
r.aprovado = True
|
||||
return r
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 1b. Normalização
|
||||
# --------------------------------------------------------------------------
|
||||
def normalizar(caminho: str, pasta_saida: str, pedido: str) -> tuple[str, str]:
|
||||
"""
|
||||
Converte o que o cliente mandou para o formato de trabalho da sala.
|
||||
|
||||
Devolve (caminho_normalizado, natureza).
|
||||
|
||||
O ORIGINAL É SEMPRE PRESERVADO. Se a conversão sair ruim num caso
|
||||
específico, o designer volta ao arquivo do cliente — pedido dos próprios
|
||||
designers, e é barato perto de refazer o trabalho.
|
||||
"""
|
||||
ext = os.path.splitext(caminho)[1].lower()
|
||||
|
||||
if ext in FORMATOS_SEM_VALIDACAO: # CDR: sem Corel no servidor
|
||||
return caminho, "conferir"
|
||||
|
||||
if ext in VETORIAIS:
|
||||
# mantém vetor: nada de rasterizar. O FlexiPRINT rasteriza na resolução
|
||||
# da máquina, na hora de imprimir — melhor que qualquer caminho manual.
|
||||
destino = os.path.join(pasta_saida, f"{pedido}_norm.pdf")
|
||||
if ext == ".pdf":
|
||||
shutil.copy(caminho, destino)
|
||||
else:
|
||||
# AI, SVG e EPS convertem para PDF preservando o vetor
|
||||
subprocess.run(
|
||||
["inkscape", caminho, "--export-type=pdf",
|
||||
f"--export-filename={destino}", "--export-text-to-path"],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
return destino, "vetor"
|
||||
|
||||
# raster: TIF com alfa, sem compressão com perda
|
||||
destino = os.path.join(pasta_saida, f"{pedido}_norm.tif")
|
||||
img = pyvips.Image.new_from_file(caminho, access="sequential")
|
||||
if not img.hasalpha():
|
||||
img = img.bandjoin(255)
|
||||
img = _para_rgb(img)
|
||||
img.tiffsave(destino, compression="lzw", predictor="horizontal")
|
||||
return destino, "raster"
|
||||
|
||||
|
||||
def _para_rgb(img: pyvips.Image) -> pyvips.Image:
|
||||
"""
|
||||
CMYK vira RGB. Arquivo em CMYK é uma das causas de cor errada no DTF —
|
||||
o cliente monta em CMYK, a máquina trabalha em RGB, e a cor muda.
|
||||
"""
|
||||
if img.interpretation in ("cmyk", "b-w"):
|
||||
return img.colourspace("srgb")
|
||||
return img
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 2. Repetição
|
||||
# --------------------------------------------------------------------------
|
||||
def empilhar(caminho: str, repeticoes: int, destino: str) -> str:
|
||||
"""Concatena N cópias da arte na vertical, sem espaço entre elas."""
|
||||
if repeticoes <= 1:
|
||||
return caminho
|
||||
img = pyvips.Image.new_from_file(caminho, access="sequential")
|
||||
empilhado = pyvips.Image.arrayjoin([img] * repeticoes, across=1)
|
||||
empilhado.write_to_file(destino)
|
||||
return destino
|
||||
|
||||
|
||||
def montar_folha(caminhos: list[str], destino: str) -> str:
|
||||
"""
|
||||
Junta vários arquivos numa folha só, EMPILHADOS na ordem que subiram.
|
||||
|
||||
Decisão de 30/08/2026: empilhamento simples, sem encaixe lado a lado.
|
||||
São artes de 1 metro em sequência — não é arte única e não há otimização
|
||||
de largura. O cliente vê a prévia antes de fechar o pedido.
|
||||
"""
|
||||
if len(caminhos) == 1:
|
||||
return caminhos[0]
|
||||
imgs = [pyvips.Image.new_from_file(c, access="sequential") for c in caminhos]
|
||||
largura = max(i.width for i in imgs)
|
||||
# centraliza as artes mais estreitas, mantendo a largura da folha
|
||||
ajustadas = [
|
||||
i if i.width == largura
|
||||
else i.embed((largura - i.width) // 2, 0, largura, i.height)
|
||||
for i in imgs
|
||||
]
|
||||
pyvips.Image.arrayjoin(ajustadas, across=1).write_to_file(destino)
|
||||
return destino
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 3. Fatiamento
|
||||
# --------------------------------------------------------------------------
|
||||
def quantas_partes(metros_totais: float) -> int:
|
||||
return max(1, math.ceil(metros_totais / LIMITE_ARQUIVO_M))
|
||||
|
||||
|
||||
def fatiar(caminho: str, metros_totais: float, prefixo: str) -> list[str]:
|
||||
"""
|
||||
Corta em partes de no máximo 15 metros.
|
||||
1 m x 20 -> 15 + 5 (2 arquivos)
|
||||
100 m -> 15 x 6 + 10 (7 arquivos)
|
||||
"""
|
||||
n = quantas_partes(metros_totais)
|
||||
if n == 1:
|
||||
return [caminho]
|
||||
|
||||
img = pyvips.Image.new_from_file(caminho, access="random")
|
||||
px_por_metro = img.height / metros_totais
|
||||
partes, topo, restante = [], 0, metros_totais
|
||||
|
||||
for i in range(n):
|
||||
m = min(LIMITE_ARQUIVO_M, restante)
|
||||
altura = int(round(m * px_por_metro))
|
||||
altura = min(altura, img.height - topo)
|
||||
saida = f"{prefixo}_p{i + 1}.png"
|
||||
img.crop(0, topo, img.width, altura).write_to_file(saida)
|
||||
partes.append(saida)
|
||||
topo += altura
|
||||
restante -= m
|
||||
|
||||
return partes
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 4. Carimbo
|
||||
# --------------------------------------------------------------------------
|
||||
def _qr_vips(conteudo: str, lado_px: int) -> pyvips.Image:
|
||||
qr = qrcode.QRCode(
|
||||
version=None,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_M, # aguenta 15% de dano
|
||||
box_size=1,
|
||||
border=0,
|
||||
)
|
||||
qr.add_data(conteudo)
|
||||
qr.make(fit=True)
|
||||
m = qr.get_matrix()
|
||||
n = len(m)
|
||||
|
||||
# matriz -> imagem 1 bit -> escala para o tamanho pedido
|
||||
dados = bytes(0 if v else 255 for linha in m for v in linha)
|
||||
img = pyvips.Image.new_from_memory(dados, n, n, 1, "uchar")
|
||||
return img.resize(lado_px / n, kernel="nearest")
|
||||
|
||||
|
||||
def carimbar(
|
||||
caminho: str,
|
||||
pedido: str,
|
||||
metros: float,
|
||||
url_kanban: str,
|
||||
dpi: float = 300.0,
|
||||
quando: datetime | None = None,
|
||||
) -> str:
|
||||
"""
|
||||
Escreve o carimbo na faixa de 30 mm do rodapé.
|
||||
QR de 20 mm com a URL do card + número, metragem e data ao lado.
|
||||
Só canal preto — sem branco de apoio, para não gastar a tinta mais cara.
|
||||
"""
|
||||
quando = quando or datetime.now()
|
||||
img = pyvips.Image.new_from_file(caminho, access="random")
|
||||
px_mm = dpi / CM_POR_POLEGADA / 10
|
||||
|
||||
qr_px = int(QR_MM * px_mm)
|
||||
margem = int(ZONA_LIMPA_MM * px_mm)
|
||||
faixa_px = int(FAIXA_RODAPE_MM * px_mm)
|
||||
|
||||
# fundo branco só sob o QR, para o celular ler com contraste
|
||||
fundo = pyvips.Image.black(qr_px + margem * 2, qr_px + margem * 2) + 255
|
||||
qr = _qr_vips(url_kanban, qr_px)
|
||||
bloco = fundo.insert(qr, margem, margem)
|
||||
|
||||
# texto alinhado à altura do QR
|
||||
texto = (
|
||||
f"<span size='{int(qr_px*0.28)*1000}' weight='bold'>{pedido}</span>\n"
|
||||
f"<span size='{int(qr_px*0.19)*1000}'>{metros:.2f} m</span>\n"
|
||||
f"<span size='{int(qr_px*0.15)*1000}' foreground='#555555'>"
|
||||
f"{quando:%d/%m · %H:%M}</span>"
|
||||
).replace(".", ",")
|
||||
txt = pyvips.Image.text(texto, dpi=int(dpi), align="low")
|
||||
|
||||
# base da faixa: transparente, o carimbo entra em preto
|
||||
base = pyvips.Image.black(img.width, faixa_px, bands=4)
|
||||
y_qr = max(0, (faixa_px - bloco.height) // 2)
|
||||
base = base.insert(bloco.bandjoin(255), margem, y_qr, expand=False)
|
||||
base = base.insert(
|
||||
txt.bandjoin(255) if txt.bands < 4 else txt,
|
||||
margem * 2 + bloco.width,
|
||||
max(0, (faixa_px - txt.height) // 2),
|
||||
expand=False,
|
||||
)
|
||||
|
||||
final = pyvips.Image.arrayjoin([img, base], across=1)
|
||||
saida = caminho.replace(".png", "_carimbado.png")
|
||||
final.write_to_file(saida)
|
||||
return saida
|
||||
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# 5. Orquestração — é isso que o portal chama
|
||||
# --------------------------------------------------------------------------
|
||||
def processar(
|
||||
caminho: str,
|
||||
cm_comprados: float,
|
||||
repeticoes: int,
|
||||
pedido: str,
|
||||
base_kanban: str,
|
||||
pasta_saida: str,
|
||||
) -> Resultado:
|
||||
# 0. normaliza antes de tudo: o designer nunca mais converte à mão
|
||||
caminho, natureza = normalizar(caminho, pasta_saida, pedido)
|
||||
if natureza == "conferir":
|
||||
r = Resultado(aprovado=True, conferir_manual=True)
|
||||
r.avisos.append("CDR: sem validação automática, vai direto ao designer.")
|
||||
r.partes = [caminho]
|
||||
return r
|
||||
|
||||
r = validar(caminho, cm_comprados)
|
||||
r.natureza = natureza
|
||||
if not r.aprovado:
|
||||
return r
|
||||
|
||||
r.metros_totais = cm_comprados / 100 * repeticoes
|
||||
prefixo = os.path.join(pasta_saida, pedido)
|
||||
|
||||
empilhado = empilhar(caminho, repeticoes, f"{prefixo}_full.png")
|
||||
partes = fatiar(empilhado, r.metros_totais, prefixo)
|
||||
|
||||
# o carimbo vai só na última parte: é ela que fecha o pedido
|
||||
partes[-1] = carimbar(
|
||||
partes[-1],
|
||||
pedido=pedido,
|
||||
metros=r.metros_totais,
|
||||
url_kanban=f"{base_kanban}/p/{pedido}",
|
||||
dpi=r.dpi_efetivo,
|
||||
)
|
||||
r.partes = partes
|
||||
return r
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# conferência rápida da regra de fatiamento
|
||||
for m in (20, 15, 16, 45, 100):
|
||||
print(f"{m:>4} m -> {quantas_partes(m)} arquivo(s)")
|
||||
@@ -1,89 +0,0 @@
|
||||
"""
|
||||
Cliente da API do Tiny (Olist) — v3, OAuth2 client credentials.
|
||||
|
||||
ATENÇÃO Wagner: confirmar na documentação oficial antes de subir:
|
||||
- o endpoint exato de marcadores do pedido
|
||||
- o limite de requisições por minuto (dimensiona o worker)
|
||||
- se o refresh token expira e com que frequência
|
||||
Este módulo isola a API: se o endpoint mudar, muda só aqui.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import time
|
||||
|
||||
import httpx
|
||||
|
||||
BASE = os.environ.get("TINY_BASE", "https://api.tiny.com.br/public-api/v3")
|
||||
CLIENT_ID = os.environ["TINY_CLIENT_ID"]
|
||||
CLIENT_SECRET = os.environ["TINY_CLIENT_SECRET"]
|
||||
TOKEN_URL = os.environ["TINY_TOKEN_URL"]
|
||||
|
||||
_token: dict = {"valor": None, "expira": 0.0}
|
||||
|
||||
|
||||
def _acesso() -> str:
|
||||
if _token["valor"] and time.time() < _token["expira"] - 60:
|
||||
return _token["valor"]
|
||||
r = httpx.post(TOKEN_URL, data={
|
||||
"grant_type": "client_credentials",
|
||||
"client_id": CLIENT_ID,
|
||||
"client_secret": CLIENT_SECRET,
|
||||
}, timeout=20)
|
||||
r.raise_for_status()
|
||||
d = r.json()
|
||||
_token["valor"] = d["access_token"]
|
||||
_token["expira"] = time.time() + d.get("expires_in", 3600)
|
||||
return _token["valor"]
|
||||
|
||||
|
||||
def _headers() -> dict:
|
||||
return {"Authorization": f"Bearer {_acesso()}", "Content-Type": "application/json"}
|
||||
|
||||
|
||||
def buscar_pedido(numero: str) -> dict:
|
||||
r = httpx.get(f"{BASE}/pedidos", params={"numero": numero},
|
||||
headers=_headers(), timeout=20)
|
||||
r.raise_for_status()
|
||||
itens = r.json().get("itens") or []
|
||||
if not itens:
|
||||
raise LookupError(f"pedido {numero} não encontrado no Tiny")
|
||||
return itens[0]
|
||||
|
||||
|
||||
def transferido_para_deposito(sku: str, deposito: str, dias: int) -> float:
|
||||
"""
|
||||
Soma o que foi transferido para o depósito de impressão no período.
|
||||
|
||||
A Altus já faz essa transferência hoje, porque também revende insumo — o
|
||||
depósito separa consumo interno de revenda. Por isso o aproveitamento sai
|
||||
de graça: nenhum apontamento novo na sala.
|
||||
|
||||
ATENÇÃO Wagner: confirmar o endpoint de movimentações de estoque por
|
||||
depósito na API v3 e o nome exato do depósito no cadastro.
|
||||
"""
|
||||
r = httpx.get(
|
||||
f"{BASE}/estoque/movimentacoes",
|
||||
params={"codigo": sku, "deposito": deposito, "dias": dias, "tipo": "E"},
|
||||
headers=_headers(), timeout=30,
|
||||
)
|
||||
r.raise_for_status()
|
||||
return sum(float(m.get("quantidade", 0)) for m in r.json().get("itens", []))
|
||||
|
||||
|
||||
def marcador(numero: str, marcador: str) -> None:
|
||||
"""
|
||||
Troca o marcador do pedido. Substitui os marcadores de etapa do DTF,
|
||||
preservando marcadores de outra natureza (multiempresa, VALE, Troca...).
|
||||
"""
|
||||
pedido = buscar_pedido(numero)
|
||||
atuais = [m["descricao"] for m in pedido.get("marcadores", [])]
|
||||
# só três marcadores de etapa vivem no Tiny; o resto do fluxo é do kanban
|
||||
ETAPAS = {"DTF-RECEBIDA", "DTF-PRODUCAO", "CORRECAO DTF", "DTF-PRONTO"}
|
||||
mantem = [m for m in atuais if m not in ETAPAS]
|
||||
novos = mantem + [marcador]
|
||||
|
||||
r = httpx.put(f"{BASE}/pedidos/{pedido['id']}/marcadores",
|
||||
json={"marcadores": [{"descricao": m} for m in novos]},
|
||||
headers=_headers(), timeout=20)
|
||||
r.raise_for_status()
|
||||
@@ -1,68 +0,0 @@
|
||||
"""
|
||||
Envio de WhatsApp. Três avisos ao cliente, não sete:
|
||||
aprovada · em produção · finalizada (+ correção, a única que pede resposta)
|
||||
|
||||
Provedor definido por WHATS_PROVEDOR = meta | zapi
|
||||
A API oficial da Meta exige template aprovado para mensagem iniciada por nós.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
|
||||
import httpx
|
||||
|
||||
PROVEDOR = os.environ.get("WHATS_PROVEDOR", "meta")
|
||||
TOKEN = os.environ.get("WHATS_TOKEN", "")
|
||||
NUMERO_ID = os.environ.get("WHATS_NUMERO_ID", "")
|
||||
ZAPI_URL = os.environ.get("ZAPI_URL", "")
|
||||
|
||||
TEXTOS = {
|
||||
"prova_cor": ("Antes de imprimir o pedido {pedido} inteiro, fizemos uma amostra "
|
||||
"de cor. Confira a foto e responda APROVO para seguirmos. A cor no "
|
||||
"filme pode variar em relação ao seu monitor."),
|
||||
"producao": "Seu pedido {pedido} entrou em produção. Avisamos quando ficar pronto.",
|
||||
"concluido": "Pedido {pedido} finalizado e pronto para retirada ou envio.",
|
||||
"correcao": "Precisamos de um ajuste na arte do pedido {pedido}: {motivo}",
|
||||
}
|
||||
|
||||
|
||||
def _enviar(telefone: str, texto: str) -> None:
|
||||
if PROVEDOR == "zapi":
|
||||
httpx.post(ZAPI_URL, json={"phone": telefone, "message": texto}, timeout=20)
|
||||
return
|
||||
httpx.post(
|
||||
f"https://graph.facebook.com/v20.0/{NUMERO_ID}/messages",
|
||||
headers={"Authorization": f"Bearer {TOKEN}"},
|
||||
json={"messaging_product": "whatsapp", "to": telefone,
|
||||
"type": "text", "text": {"body": texto}},
|
||||
timeout=20,
|
||||
).raise_for_status()
|
||||
|
||||
|
||||
def enviar_link(telefone: str, pedido: str, link: str) -> None:
|
||||
_enviar(telefone,
|
||||
f"Pedido {pedido} confirmado. Envie sua arte por aqui: {link}\n"
|
||||
"O link vale por 7 dias. Gabarito 57 × 97 cm, PNG 300 DPI com fundo "
|
||||
"transparente.")
|
||||
|
||||
|
||||
def enviar_aprovacao(pedido, r) -> None:
|
||||
n = len(r.partes)
|
||||
_enviar(pedido.telefone if hasattr(pedido, "telefone") else "",
|
||||
f"Arte do pedido {pedido.numero_tiny} aprovada. "
|
||||
f"{r.metros_totais:.2f} m em {n} arquivo{'s' if n > 1 else ''}. "
|
||||
"Entrou na fila de impressão.".replace(".", ",", 1))
|
||||
|
||||
|
||||
def enviar_recusa(pedido, motivo: str) -> None:
|
||||
_enviar(getattr(pedido, "telefone", ""),
|
||||
f"A arte do pedido {pedido.numero_tiny} precisa de ajuste: {motivo}\n"
|
||||
"O prazo só começa a contar depois que a arte for aprovada.")
|
||||
|
||||
|
||||
def avisar(pedido: str, tipo: str, motivo: str = "") -> None:
|
||||
# o telefone vem do cadastro do pedido no Tiny
|
||||
import tiny
|
||||
p = tiny.buscar_pedido(pedido)
|
||||
telefone = (p.get("cliente") or {}).get("fone", "")
|
||||
_enviar(telefone, TEXTOS[tipo].format(pedido=pedido, motivo=motivo))
|
||||
222
schema.sql
222
schema.sql
@@ -1,222 +0,0 @@
|
||||
-- =====================================================================
|
||||
-- Módulo DTF — schema completo
|
||||
-- =====================================================================
|
||||
-- Duas bases:
|
||||
-- NUVEM (PostgreSQL, no VPS) → pedido, arte, parte, job, agente
|
||||
-- LOCAL (SQLite, na fábrica) → card, movimento, retrabalho, job_local
|
||||
--
|
||||
-- A separação é proposital: o kanban precisa funcionar com a internet fora.
|
||||
-- =====================================================================
|
||||
|
||||
|
||||
-- =====================================================================
|
||||
-- NUVEM · PostgreSQL
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE pedido (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
numero_tiny TEXT NOT NULL UNIQUE, -- chave de tudo
|
||||
cliente TEXT NOT NULL,
|
||||
telefone TEXT NOT NULL,
|
||||
metros NUMERIC(10,2) NOT NULL, -- metros comprados por arte
|
||||
cliente_novo BOOLEAN NOT NULL DEFAULT false,-- dispara prova de cor
|
||||
token TEXT NOT NULL UNIQUE, -- UUID v4 do link
|
||||
token_expira TIMESTAMPTZ NOT NULL, -- 7 dias
|
||||
lembrete_em TIMESTAMPTZ, -- 1h sem arte → cobra
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ix_pedido_token ON pedido(token);
|
||||
CREATE INDEX ix_pedido_semarte ON pedido(criado_em)
|
||||
WHERE NOT EXISTS (SELECT 1 FROM arte WHERE arte.pedido_id = pedido.id);
|
||||
|
||||
|
||||
CREATE TABLE arte (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
pedido_id BIGINT NOT NULL REFERENCES pedido(id),
|
||||
arquivo TEXT NOT NULL, -- chave no storage
|
||||
arquivo_orig TEXT, -- original preservado
|
||||
bytes BIGINT NOT NULL DEFAULT 0,
|
||||
formato_orig TEXT, -- png, pdf, psd, cdr...
|
||||
natureza TEXT, -- vetor | raster | conferir
|
||||
repeticoes INT NOT NULL DEFAULT 1,
|
||||
status TEXT NOT NULL DEFAULT 'recebida',
|
||||
-- recebida | processando | aprovada | recusada
|
||||
conferir_manual BOOLEAN NOT NULL DEFAULT false,-- CDR
|
||||
motivo TEXT, -- por que foi recusada
|
||||
avisos TEXT, -- aceitou mas com ressalva
|
||||
dpi_efetivo NUMERIC(8,2),
|
||||
largura_cm NUMERIC(8,2),
|
||||
metros_totais NUMERIC(10,2),
|
||||
minutos_maquina INT, -- estimativa; vira campo no card
|
||||
cores_tensas TEXT, -- fora do gamut, se houver
|
||||
virus_ok BOOLEAN NOT NULL DEFAULT false,-- ClamAV liberou
|
||||
baixada BOOLEAN NOT NULL DEFAULT false,-- o agente já trouxe
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ix_arte_pedido ON arte(pedido_id);
|
||||
CREATE INDEX ix_arte_baixar ON arte(status, baixada, virus_ok);
|
||||
|
||||
|
||||
CREATE TABLE parte (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
arte_id BIGINT NOT NULL REFERENCES arte(id),
|
||||
ordem INT NOT NULL,
|
||||
metros NUMERIC(10,2) NOT NULL,
|
||||
arquivo TEXT NOT NULL,
|
||||
carimbada BOOLEAN NOT NULL DEFAULT false,-- só a última leva carimbo
|
||||
UNIQUE (arte_id, ordem)
|
||||
);
|
||||
|
||||
|
||||
-- Fila de jobs. Tabela, não broker: no volume da Altus (algumas centenas de
|
||||
-- pedidos/dia) Redis e Celery são complexidade sem retorno.
|
||||
-- Consumir com: SELECT ... FOR UPDATE SKIP LOCKED
|
||||
CREATE TABLE job (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
tipo TEXT NOT NULL, -- preflight | whats | tiny | limpeza
|
||||
payload JSONB NOT NULL,
|
||||
tentativas INT NOT NULL DEFAULT 0,
|
||||
proxima_em TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
erro TEXT,
|
||||
feito BOOLEAN NOT NULL DEFAULT false,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
CREATE INDEX ix_job_fila ON job(feito, proxima_em) WHERE NOT feito;
|
||||
|
||||
|
||||
CREATE TABLE agente_heartbeat (
|
||||
id SERIAL PRIMARY KEY,
|
||||
visto_em TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
-- sem sinal por 15 min → alertar o TI
|
||||
|
||||
|
||||
-- Artes guardadas para recompra. Prefixo separado no storage, retenção 12 meses.
|
||||
CREATE TABLE arte_cliente (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
numero_cliente TEXT NOT NULL,
|
||||
token_cliente TEXT NOT NULL, -- link permanente
|
||||
arte_id BIGINT REFERENCES arte(id),
|
||||
arquivo TEXT NOT NULL, -- só a arte tratada
|
||||
descricao TEXT,
|
||||
criado_em TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||
expira_em TIMESTAMPTZ NOT NULL
|
||||
);
|
||||
CREATE INDEX ix_arte_cliente ON arte_cliente(numero_cliente, token_cliente);
|
||||
|
||||
|
||||
-- =====================================================================
|
||||
-- LOCAL · SQLite (na fábrica)
|
||||
-- =====================================================================
|
||||
|
||||
CREATE TABLE card (
|
||||
arte_id INTEGER PRIMARY KEY,
|
||||
pedido TEXT NOT NULL,
|
||||
cliente TEXT NOT NULL,
|
||||
metros REAL NOT NULL,
|
||||
minutos_maquina INTEGER, -- estimativa. Herda os marcadores 30min/1h/3h
|
||||
-- do Tiny. Sugerida por metros/20*60; quem
|
||||
-- trata a arte ajusta só quando foge do normal
|
||||
partes INTEGER NOT NULL DEFAULT 1,
|
||||
coluna TEXT NOT NULL DEFAULT 'rec',
|
||||
-- rec | tra | apc | fil | imp | cor | fin
|
||||
-- NÃO existe coluna de aplicação: a sala só imprime o filme
|
||||
desde TEXT NOT NULL, -- entrada na coluna atual (ISO)
|
||||
maquina TEXT, -- 'Maq 1' … 'Maq 6'
|
||||
reservado_por TEXT, -- máquina que puxou
|
||||
reservado_em TEXT, -- expira em 3 min (ajuste da sala)
|
||||
conferir_manual INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX ix_card_coluna ON card(coluna, desde);
|
||||
|
||||
|
||||
-- A tabela mais importante do sistema. Dela saem TODOS os números que hoje
|
||||
-- não existem: tempo por etapa, fila por máquina, retrabalho, ocupação real.
|
||||
CREATE TABLE movimento (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
arte_id INTEGER NOT NULL,
|
||||
pedido TEXT NOT NULL,
|
||||
de TEXT NOT NULL,
|
||||
para TEXT NOT NULL,
|
||||
entrou_em TEXT NOT NULL,
|
||||
saiu_em TEXT NOT NULL,
|
||||
segundos INTEGER NOT NULL,
|
||||
usuario TEXT NOT NULL, -- login do PCP. Se o PC tiver login
|
||||
-- compartilhado, o indicador de retrabalho
|
||||
-- por pessoa fica sem dono
|
||||
maquina TEXT
|
||||
);
|
||||
CREATE INDEX ix_mov_arte ON movimento(arte_id);
|
||||
CREATE INDEX ix_mov_saiu ON movimento(saiu_em);
|
||||
CREATE INDEX ix_mov_etapa ON movimento(de, saiu_em);
|
||||
|
||||
|
||||
CREATE TABLE retrabalho (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
pedido_origem TEXT NOT NULL,
|
||||
pedido_novo TEXT,
|
||||
metros REAL NOT NULL,
|
||||
causa TEXT NOT NULL,
|
||||
-- arte_cliente | tratamento | impressao | perfil_cor | pedido
|
||||
-- TRAVADA depois de aberta: com o indicador atrelado a bônus,
|
||||
-- haveria incentivo para reclassificar falha de máquina como
|
||||
-- culpa do cliente
|
||||
aberto_por TEXT NOT NULL, -- Mayana: conhece o cliente e a reclamação
|
||||
aberto_em TEXT NOT NULL,
|
||||
vez INTEGER NOT NULL DEFAULT 1, -- 2ª sobe alçada, 3ª trava
|
||||
alcada TEXT NOT NULL, -- sala | financeiro | diretoria
|
||||
autorizado_por TEXT, -- Thales ou Alexandre: entra na meta deles
|
||||
autorizado_em TEXT,
|
||||
contestado_por TEXT, -- discorda da causa mas não pode mudá-la
|
||||
contestado_em TEXT,
|
||||
contestacao TEXT,
|
||||
evidencia TEXT -- foto ou print da reclamação
|
||||
);
|
||||
CREATE INDEX ix_retra_data ON retrabalho(aberto_em);
|
||||
CREATE INDEX ix_retra_causa ON retrabalho(causa, aberto_em);
|
||||
|
||||
|
||||
CREATE TABLE job_local (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
tipo TEXT NOT NULL, -- tiny_marcador | whats
|
||||
payload TEXT NOT NULL,
|
||||
tentativas INTEGER NOT NULL DEFAULT 0,
|
||||
proxima_em REAL NOT NULL, -- 1, 5 e 30 min
|
||||
erro TEXT,
|
||||
feito INTEGER NOT NULL DEFAULT 0
|
||||
);
|
||||
CREATE INDEX ix_joblocal ON job_local(feito, proxima_em);
|
||||
|
||||
|
||||
-- Consumo de filme, para o aproveitamento. NÃO exige apontamento novo:
|
||||
-- lê as transferências para o depósito de impressão no Tiny.
|
||||
CREATE TABLE consumo_filme (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
em TEXT NOT NULL,
|
||||
metros REAL NOT NULL,
|
||||
origem TEXT NOT NULL DEFAULT 'tiny' -- transferência de depósito
|
||||
);
|
||||
|
||||
|
||||
-- =====================================================================
|
||||
-- Consultas que o painel usa
|
||||
-- =====================================================================
|
||||
|
||||
-- Tempo médio por etapa nos últimos N dias
|
||||
-- SELECT de, COUNT(*) n, AVG(segundos)/60 media_min, MAX(segundos)/60 pior_min
|
||||
-- FROM movimento WHERE saiu_em >= date('now','-7 days') GROUP BY de;
|
||||
|
||||
-- Velocidade real por máquina — é o número que valida ou derruba os 20 m/h
|
||||
-- SELECT m.maquina, SUM(c.metros) metros, SUM(m.segundos)/3600.0 horas,
|
||||
-- SUM(c.metros)/(SUM(m.segundos)/3600.0) m_por_hora
|
||||
-- FROM movimento m JOIN card c ON c.arte_id = m.arte_id
|
||||
-- WHERE m.de='imp' AND m.saiu_em >= date('now','-7 days') GROUP BY m.maquina;
|
||||
|
||||
-- Retrabalho por causa, com responsável
|
||||
-- SELECT causa, COUNT(*) n, SUM(metros) m FROM retrabalho
|
||||
-- WHERE aberto_em >= date('now','-30 days') GROUP BY causa;
|
||||
|
||||
-- Aproveitamento do filme
|
||||
-- faturado = SELECT SUM(metros) FROM card WHERE coluna='fin' AND desde >= ...
|
||||
-- consumido = SELECT SUM(metros) FROM consumo_filme WHERE em >= ...
|
||||
-- aproveitamento = faturado / consumido
|
||||
Reference in New Issue
Block a user