558 lines
23 KiB
Python
558 lines
23 KiB
Python
"""
|
|
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")
|