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