Files
dtf-system/kanban/main.py
Cauê Faleiros 98c951d374
Some checks failed
Validate, publish and deploy / validate (push) Successful in 2m2s
Validate, publish and deploy / publish-and-deploy (push) Failing after 8s
first commit
2026-09-15 16:42:34 -03:00

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