diff --git a/.audit_report.json b/.audit_report.json new file mode 100644 index 0000000000000000000000000000000000000000..0e3e20967561c94621392785803ddc50c6f74f90 --- /dev/null +++ b/.audit_report.json @@ -0,0 +1,219 @@ +{ + "duplicate_keys": {}, + "widgets_without_key": { + ".\\app.py": { + "button_no_key": [ + 445 + ] + }, + ".\\app_outlook.py": { + "download_no_key": [ + 24, + 36, + 82 + ] + }, + ".\\auditoria.py": { + "download_no_key": [ + 91 + ] + }, + ".\\auditoria_cleanup.py": { + "button_no_key": [ + 65 + ] + }, + ".\\consulta.py": { + "download_no_key": [ + 171 + ] + }, + ".\\db_admin.py": { + "button_no_key": [ + 213, + 241, + 263, + 316, + 360 + ] + }, + ".\\db_export_import.py": { + "button_no_key": [ + 273, + 285, + 301, + 320, + 333 + ], + "download_no_key": [ + 277, + 289, + 305 + ] + }, + ".\\db_monitor.py": { + "button_no_key": [ + 261, + 265 + ] + }, + ".\\db_tools.py": { + "button_no_key": [ + 57 + ] + }, + ".\\importar_excel.py": { + "download_no_key": [ + 77 + ] + }, + ".\\jogos.py": { + "button_no_key": [ + 104, + 256, + 356 + ] + }, + ".\\login.py": { + "button_no_key": [ + 83 + ] + }, + ".\\operacao.py": { + "button_no_key": [ + 1444, + 1452, + 1471, + 1487 + ], + "download_no_key": [ + 1543, + 1547 + ] + }, + ".\\outlook_relatorio.py": { + "download_no_key": [ + 30, + 42, + 79 + ] + }, + ".\\Produtividade_Especialista.py": { + "button_no_key": [ + 52 + ], + "download_no_key": [ + 506, + 518 + ] + }, + ".\\quiz.py": { + "button_no_key": [ + 107 + ] + }, + ".\\quiz_admin.py": { + "button_no_key": [ + 57 + ] + }, + ".\\ranking.py": { + "download_no_key": [ + 109 + ] + }, + ".\\repositorio_load.py": { + "download_no_key": [ + 251, + 344 + ] + }, + ".\\videos.py": { + "button_no_key": [ + 65, + 85 + ] + } + }, + "missing_imports_in_app": [], + "routing_vs_modules": { + "routes_without_modules_entry": [], + "modules_entry_without_route": [ + "administracao", + "auditoria", + "auditoria_cleanup", + "backload_consulta", + "calendario", + "calendario_mensal", + "consulta", + "db_admin", + "db_export_import", + "db_monitor", + "formulario", + "importacao", + "indicadores", + "jogos", + "operacao", + "outlook_relatorio", + "produtividade_especialista", + "quiz", + "quiz_admin", + "ranking", + "relatorio", + "repositorio_load", + "resposta", + "sugestoes_ioirun", + "terceiros_gestao", + "usuarios", + "videos" + ] + }, + "module_files_missing": [], + "modules_without_main": [], + "unused_imports": { + ".\\auto_capture.py": [ + "TimeoutError" + ], + ".\\calendario_mensal.py": [ + "formatar_data_br" + ], + ".\\db_admin.py": [ + "SessionLocal" + ], + ".\\db_export_import.py": [ + "SessionLocal" + ], + ".\\db_monitor.py": [ + "time", + "SessionLocal", + "verificar_permissao" + ], + ".\\env_audit.py": [ + "Tuple" + ], + ".\\init_db.py": [ + "models" + ], + ".\\modules_map.py": [ + "calendario", + "calendario_mensal" + ], + ".\\utils_auditoria.py": [ + "db_info" + ], + ".\\utils_campos.py": [ + "Equipamento" + ], + ".\\utils_datas.py": [ + "date" + ], + ".\\utils_lembretes.py": [ + "date" + ], + ".\\utils_operacao.py": [ + "annotations", + "st" + ] + }, + "import_cycles": [] +} \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000000000000000000000000000000000000..7a5f65f8711dfd201a36fdc15c8382183459ada5 --- /dev/null +++ b/.env @@ -0,0 +1,79 @@ + +# --- Mayasuite API (Operação) --- +OP_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc2ODQ4NjI5NywianRpIjoiNTQ1NjdkYmUtZGUxZi00ZDAxLTkzYzktZGRiYzk4MGJmYWNlIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6IjgzNDAxM2QzLWJhNTMtNDQ1MC1hZmJlLTc4ODZhZjQ5MjJiNCIsIm5iZiI6MTc2ODQ4NjI5NywiY3NyZiI6IjQ3OGNjM2RiLTU5ZGItNDU5NS04ZjdjLWQzM2RhMDAzMjZhMCIsImV4cCI6MTc2ODUyOTQ5N30.R3Bi6c9uxjv8ehvT6JqIshgQqiTJIP8Lm4XlmY-bStg +# Alternativas (opcionais, usadas só se a primária falhar) +OP_LOGIN_EMAIL_ALT=api@armmatriz.com.br +OP_LOGIN_PASSWORD_ALT=Arm@2025 + +# Ativa logs de corpo de erro (apenas DIAGNÓSTICO TEMPORÁRIO) +OP_LOGIN_DEBUG=true +# Configurações de requisição +OP_READ_TIMEOUT=60 # p.ex., 60s (alguns endpoints demoram mais) +OP_RATE_DELAY_SEC=0.5 # atraso menor entre páginas +OP_MAX_PAGES=1 # padrão apenas 1 página (você controla na UI) +OP_MAX_RETRIES_5XX=3 # menos tentativas para 5xx +OP_5XX_BACKOFF_BASE=2 # backoff mais curto +OP_RETRY_TIMEOUT_TOTAL=90 # timeout total menor para retries +# --- Fim Mayasuite API (Operação) --- + + +# ================================ +# 🔀 Bancos (Multi‑ambiente SQLite) +# ================================ +# Utilize estes URLs caso deseje ler os caminhos pelo .env. +# Para ativar no db_router.py, DESCOMENTE o bloco de dotenv nele. +DB1_PROD_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load.db +DB2_TEST_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load_teste.db +DB3_TREINAMENTO_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load_treinamento.db + +# (Opcional) rótulos amigáveis por ambiente (se quiser ler via .env) +DB1_LABEL=Banco 1 (📗 Produção) +DB2_LABEL=Banco 2 (📕 Teste) +DB3_LABEL=Banco 3 (📘 Treinamento) + + +# ================================== +# 🤖 Automação de captura/apresentação +# ================================== +# Usado pelo script auto_capture.py (Playwright + python-pptx) +APP_URL=http://localhost:8501 + +# Usuário/senha para login automático (recomendado perfil admin em Teste/Treinamento) +LOGIN_USER=admin +LOGIN_PASS=admin123 + +# Ambiente alvo para captura: prod | test | treinamento +BANK_CHOICE=prod + +# Saídas de captura e apresentação +SCREEN_DIR=./screenshots +OUTPUT_PPTX=./demo_funcionalidades.pptx + +# (Opcional) parâmetros da captura +AUTOCAPTURE_HEADLESS=false # true = sem abrir janela; false = visível +AUTOCAPTURE_VIEWPORT_W=1440 +AUTOCAPTURE_VIEWPORT_H=900 + +# (Opcional) pular quiz durante captura (se seu login exigir quiz) +AUTOCAPTURE_SKIP_QUIZ=true + + +# ========================== +# 🧰 Monitor/Backup do banco +# ========================== +# Diretório padrão de backups (db_monitor.py) +BACKUP_DIR=./backups +BACKUP_RETAIN=10 # manter N arquivos mais recentes +BACKUP_FREQ_DAYS=7 # frequência "prevista" em dias + +# (Opcional) mostrar URL do engine na sidebar (se usar no app.py) +SHOW_ENGINE_URL_IN_SIDEBAR=true + + +# ========================== +# 🔧 Streamlit (opcional) +# ========================== +# STREAMLIT_SERVER_ADDRESS=0.0.0.0 +# STREAMLIT_SERVER_PORT=8501 +# STREAMLIT_BROWSER_GATHER_USAGE_STATS=false +# STREAMLIT_THEME_BASE="light" diff --git a/.gitattributes b/.gitattributes index c7d9f3332a950355d5a77d85000f05e6f45435ea..b42af56e3ebeb01310cbc40efd357fcee1f01291 100644 --- a/.gitattributes +++ b/.gitattributes @@ -32,3 +32,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +Load.db filter=lfs diff=lfs merge=lfs -text +Load.db.bak filter=lfs diff=lfs merge=lfs -text diff --git a/.gitignore b/.gitignore index db8d52dd6648e76ca54273d649d2e02d07618cfc..9e42c2ced9f8db9048a510aa6e4725939c7f815b 100644 --- a/.gitignore +++ b/.gitignore @@ -1,132 +1,42 @@ -# Byte-compiled / optimized / DLL files -__pycache__/ -*.py[cod] -*$py.class -# *.html -private/ -.vscode/ - -# C extensions -*.so - -# Distribution / packaging -.Python -build/ -develop-eggs/ -dist/ -downloads/ -eggs/ -.eggs/ -lib/ -lib64/ -parts/ -sdist/ -var/ -wheels/ -pip-wheel-metadata/ -share/python-wheels/ -*.egg-info/ -.installed.cfg -*.egg -MANIFEST - -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - -# Installer logs -pip-log.txt -pip-delete-this-directory.txt - -# Unit test / coverage reports -htmlcov/ -.tox/ -.nox/ -.coverage -.coverage.* -.cache -nosetests.xml -coverage.xml -*.cover -*.py,cover -.hypothesis/ -.pytest_cache/ - -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py -db.sqlite3 -db.sqlite3-journal - -# Flask stuff: -instance/ -.webassets-cache - -# Scrapy stuff: -.scrapy - -# Sphinx documentation -docs/_build/ - -# PyBuilder -target/ - -# Jupyter Notebook -.ipynb_checkpoints - -# IPython -profile_default/ -ipython_config.py - -# pyenv -.python-version - -# pipenv -# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. -# However, in case of collaboration, if having platform-specific dependencies or dependencies -# having no cross-platform support, pipenv may install dependencies that don't work, or not -# install all needed dependencies. -#Pipfile.lock - -# PEP 582; used by e.g. github.com/David-OConnor/pyflow -__pypackages__/ - -# Celery stuff -celerybeat-schedule -celerybeat.pid - -# SageMath parsed files -*.sage.py - -# Environments -.env -.venv -env/ -venv/ -ENV/ -env.bak/ -venv.bak/ - -# Spyder project settings -.spyderproject -.spyproject - -# Rope project settings -.ropeproject - -# mkdocs documentation -/site - -# mypy -.mypy_cache/ -.dmypy.json -dmypy.json - -# Pyre type checker -.pyre/ +# ======================== +# Python +# ======================== +__pycache__/ +*.pyc +*.pyo +*.pyd + +# ======================== +# Ambiente virtual +# ======================== +venv/ +env/ +.venv/ + +# ======================== +# Variáveis de ambiente +# ======================== +.env + +# ======================== +# Banco de dados +# ======================== +*.db +*.sqlite +*.sqlite3 + +# ======================== +# Streamlit +# ======================== +.streamlit/ + +# ======================== +# Logs +# ======================== +*.log + +# ======================== +# Sistema operacional +# ======================== +.DS_Store +Thumbs.db diff --git a/.txt b/.txt new file mode 100644 index 0000000000000000000000000000000000000000..5867987ac0dc721847de489e3d01bf737833f5d3 --- /dev/null +++ b/.txt @@ -0,0 +1,2 @@ +DATABASE_URL=postgresql://... +SENHA_ADMIN=admin123 diff --git a/20260129_add_po_alt_pn_lot_batch.py b/20260129_add_po_alt_pn_lot_batch.py new file mode 100644 index 0000000000000000000000000000000000000000..cfc7455b20e5c0a853079dfbdc0cfeb476617692 --- /dev/null +++ b/20260129_add_po_alt_pn_lot_batch.py @@ -0,0 +1,40 @@ +"""add po_alt, pn, lot_batch to recebimento_registros + +Revision ID: 8f7c3e5a9b21 +Revises: +Create Date: 2026-01-29 13:20:00.000000 +""" + +from alembic import op +import sqlalchemy as sa + +# Revisão atual e anterior +revision = '8f7c3e5a9b21' +down_revision = '' +branch_labels = None +depends_on = None + + +def upgrade(): + # Usamos batch_alter_table p/ compatibilidade (SQLite etc.) + with op.batch_alter_table('recebimento_registros', schema=None) as batch_op: + batch_op.add_column(sa.Column('po_alt', sa.String(length=60), nullable=True)) + batch_op.add_column(sa.Column('pn', sa.String(length=120), nullable=True)) + batch_op.add_column(sa.Column('lot_batch', sa.String(length=120), nullable=True)) + + # Se desejar índices (opcionais), descomente: + # batch_op.create_index('ix_receb_po_alt', ['po_alt']) + # batch_op.create_index('ix_receb_pn', ['pn']) + # batch_op.create_index('ix_receb_lot_batch', ['lot_batch']) + + +def downgrade(): + with op.batch_alter_table('recebimento_registros', schema=None) as batch_op: + # Se criou índices acima, primeiro drope-os: + # batch_op.drop_index('ix_receb_lot_batch') + # batch_op.drop_index('ix_receb_pn') + # batch_op.drop_index('ix_receb_po_alt') + + batch_op.drop_column('lot_batch') + batch_op.drop_column('pn') + batch_op.drop_column('po_alt') \ No newline at end of file diff --git a/Inbox_Admin.py b/Inbox_Admin.py new file mode 100644 index 0000000000000000000000000000000000000000..b37e024b4eed8baa8e03f1a4a2a1ac8eb73ab56e --- /dev/null +++ b/Inbox_Admin.py @@ -0,0 +1,197 @@ + +# pages/Inbox_Admin.py +# -*- coding: utf-8 -*- +import streamlit as st +from datetime import datetime +from sqlalchemy import func + +# Model +from models import IOIRunSugestao + +# (Opcional) auditoria +try: + from utils_auditoria import registrar_log +except Exception: + registrar_log = None + +# ------------- CONFIG BÁSICA ------------- +st.set_page_config(page_title="📬 Inbox Admin • IOI-RUN", layout="wide") + +STATUS_PENDENTE = "pendente" +STATUS_RESPONDIDA = "respondida" + + +# ------------- Sessão de banco ciente do ambiente ------------- +def _get_db_session(): + """ + Retorna uma sessão de banco consistente com o ambiente atual. + Tenta usar o db_router (se presente); senão, cai para SessionLocal(). + """ + try: + from db_router import get_session_for_current_db + return get_session_for_current_db() + except Exception: + pass + try: + from banco import SessionLocal + return SessionLocal() + except Exception as e: + st.error(f"Banco indisponível: {e}") + raise + + +def _debug_banco_caption(): + """Mostra em qual banco estamos (Produção/Teste/Treinamento).""" + try: + from db_router import current_db_choice, bank_label + choice = current_db_choice() + label = bank_label(choice) + st.caption(f"🗄️ Banco ativo: **{label}**") + except Exception: + st.caption("🗄️ Banco ativo: **default**") + + +# ------------- Guarda de rota (somente admin) ------------- +def _ensure_admin(): + perfil = (st.session_state.get("perfil") or "").strip().lower() + if perfil != "admin": + st.error("Acesso negado. Esta página é restrita a administradores.") + st.stop() + + +# ------------- Página ------------- +def main(): + _ensure_admin() + + st.title("📬 Caixa de Entrada • IOI‑RUN (Admin)") + st.caption("Responda sugestões dos usuários em uma página separada, sem interferência do app principal.") + _debug_banco_caption() + + # Estados persistentes exclusivos desta página (prefixo 'adm_inbox_') + st.session_state.setdefault("adm_inbox_area", "todos") + st.session_state.setdefault("adm_inbox_status", STATUS_PENDENTE) + st.session_state.setdefault("adm_inbox_usuario", "") + st.session_state.setdefault("adm_inbox_nonce", 0) + + AREAS = ["todos", "WMS", "FPSO", "UI/UX", "Relatórios", "Integrações", "Performance", "Segurança", "Outros"] + STATUS = [STATUS_PENDENTE, STATUS_RESPONDIDA, "todos"] + + # ------------- Filtros ------------- + col_f1, col_f2, col_f3, col_f4 = st.columns([1, 1, 1, 0.6]) + col_f1.selectbox( + "Área/Tema", + AREAS, + key="adm_inbox_area", + index=AREAS.index(st.session_state["adm_inbox_area"]) if st.session_state["adm_inbox_area"] in AREAS else 0 + ) + col_f2.selectbox( + "Status", + STATUS, + key="adm_inbox_status", + index=STATUS.index(st.session_state["adm_inbox_status"]) if st.session_state["adm_inbox_status"] in STATUS else 0 + ) + col_f3.text_input( + "Filtrar por usuário (login exato)", + key="adm_inbox_usuario", + value=st.session_state["adm_inbox_usuario"] + ) + + if col_f4.button("🔄 Atualizar lista"): + st.session_state["adm_inbox_nonce"] += 1 + st.rerun() + + # ------------- Consulta ------------- + db = _get_db_session() + try: + q = db.query(IOIRunSugestao) + if st.session_state["adm_inbox_area"] != "todos": + q = q.filter(IOIRunSugestao.area == st.session_state["adm_inbox_area"]) + if st.session_state["adm_inbox_status"] != "todos": + q = q.filter(func.lower(IOIRunSugestao.status) == st.session_state["adm_inbox_status"]) + if (st.session_state["adm_inbox_usuario"] or "").strip(): + q = q.filter(IOIRunSugestao.usuario == (st.session_state["adm_inbox_usuario"] or "").strip()) + + sugestoes = q.order_by(IOIRunSugestao.data_envio.desc()).all() + except Exception as e: + st.error(f"Erro ao consultar sugestões: {e}") + sugestoes = [] + + # ------------- Lista / Edição ------------- + if not sugestoes: + st.info("Nenhuma sugestão encontrada para os filtros aplicados.") + else: + for s in sugestoes: + dt_envio = s.data_envio.strftime("%d/%m/%Y %H:%M") if s.data_envio else "—" + titulo = f"📩 {dt_envio} — {s.usuario} — Status: {s.status or '—'}" + if s.area: + titulo += f" — Área: {s.area}" + + with st.expander(titulo, expanded=False): + st.markdown("**Sugestão:**") + st.write(s.mensagem or "—") + + with st.form(key=f"adm_inbox_form_{s.id}", clear_on_submit=False): + resposta_txt = st.text_area( + f"Responder ao usuário ({s.usuario}) — ID {s.id}", + value=s.resposta or "", + key=f"adm_inbox_resposta_{s.id}", + placeholder="Digite sua resposta para este usuário…", + height=140 + ) + col_a1, col_a2 = st.columns([1, 1]) + enviar = col_a1.form_submit_button("📤 Enviar resposta") + pendenciar = col_a2.form_submit_button("⏳ Marcar como pendente") + + if enviar: + try: + s.resposta = (resposta_txt or "").strip() + s.status = STATUS_RESPONDIDA if s.resposta else STATUS_PENDENTE + s.data_resposta = datetime.now() if s.resposta else None + s.responsavel = st.session_state.get("usuario") + + db.add(s) + db.commit() + + # Auditoria (opcional) + if registrar_log and s.resposta: + try: + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"Respondeu sugestão IOI‑RUN (ID {s.id}) para {s.usuario}", + tabela="ioirun_sugestao", + registro_id=s.id + ) + except Exception: + pass + + st.success("Resposta registrada com sucesso! (Agora em 'respondida')") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar resposta: {e}") + + if pendenciar: + try: + s.status = STATUS_PENDENTE + s.resposta = None + s.data_resposta = None + s.responsavel = None + db.add(s) + db.commit() + st.info("Sugestão marcada como pendente novamente.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao alterar status: {e}") + + st.markdown("---") + st.caption("Use o **menu lateral** para navegar para outros módulos.") + + try: + db.close() + except Exception: + pass + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/Info.txt b/Info.txt new file mode 100644 index 0000000000000000000000000000000000000000..0c1c4dad637cc519fd05fc4ea5e4a7e9c4156f7e --- /dev/null +++ b/Info.txt @@ -0,0 +1,20 @@ +LoadApp/ +│ +├── app.py # Arquivo principal +├── login.py # Login +├── administracao.py # Área admin +├── formulario.py # Inclusão +├── consulta.py # Consulta +├── relatorios.py # Relatórios +│ +├── banco.py # Conexão com banco +├── models.py # Modelos SQLAlchemy +├── utils_fpso.py +├── utils_permissoes.py +│ +├── assets/ +│ └── logo.png # Logo do sistema +│ +├── requirements.txt +├── .gitignore +└── README.md diff --git a/Load.db b/Load.db new file mode 100644 index 0000000000000000000000000000000000000000..57bcd665f5291e2f932eac9e347786a117e32b92 --- /dev/null +++ b/Load.db @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1b624b9d0c5160a67fb95de8a6d21ebe03389aa8029273d8bc86216e51eec470 +size 9220096 diff --git a/Load.db.bak b/Load.db.bak new file mode 100644 index 0000000000000000000000000000000000000000..63ffba607a542f09f8dc4e4a0984fd7743a111fd --- /dev/null +++ b/Load.db.bak @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:40613ff0f3898cf9307261a0b2bc2ec4a393e8315395ef0e1c116c7d6f49bde9 +size 1196032 diff --git a/Load.py b/Load.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/Produtividade_Especialista.py b/Produtividade_Especialista.py new file mode 100644 index 0000000000000000000000000000000000000000..48e590616837c931b5f8c2ad0b650c6adbf05990 --- /dev/null +++ b/Produtividade_Especialista.py @@ -0,0 +1,778 @@ + +# -*- coding: utf-8 -*- +import streamlit as st +import pandas as pd +from io import BytesIO +from banco import SessionLocal +from models import Equipamento + +# Auto-refresh +from streamlit_autorefresh import st_autorefresh +from datetime import datetime, timedelta + +# SQL util +from sqlalchemy import text + +# ====== Gráficos: Altair (preferência) + fallback Matplotlib ====== +ALT_AVAILABLE = True +try: + import altair as alt + try: + alt.data_transformers.disable_max_rows() + except Exception: + pass +except Exception: + ALT_AVAILABLE = False + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + +# NumPy para cálculos numéricos robustos +import numpy as np + + +# =============================== +# Fotos de Responsáveis — Helpers (DB) +# =============================== +def _ensure_foto_table(db) -> None: + """Cria a tabela responsavel_foto se não existir (SQLite/PostgreSQL/MySQL).""" + dialect = db.bind.dialect.name + + if dialect == "sqlite": + sql = """ + CREATE TABLE IF NOT EXISTS responsavel_foto ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + tipo TEXT NOT NULL, -- 'especialista' | 'conferente' + nome TEXT NOT NULL, + imagem BLOB NOT NULL, -- bytes + mimetype TEXT, + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (tipo, nome) + ) + """ + elif dialect in ("postgresql", "postgres"): + sql = """ + CREATE TABLE IF NOT EXISTS responsavel_foto ( + id SERIAL PRIMARY KEY, + tipo TEXT NOT NULL, -- 'especialista' | 'conferente' + nome TEXT NOT NULL, + imagem BYTEA NOT NULL, -- bytes + mimetype TEXT, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE (tipo, nome) + ) + """ + else: # mysql/mariadb + sql = """ + CREATE TABLE IF NOT EXISTS responsavel_foto ( + id INT AUTO_INCREMENT PRIMARY KEY, + tipo VARCHAR(32) NOT NULL, + nome VARCHAR(255) NOT NULL, + imagem LONGBLOB NOT NULL, + mimetype VARCHAR(64), + updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, + UNIQUE KEY uq_tipo_nome (tipo, nome) + ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 + """ + db.execute(text(sql)) + db.commit() + + +def _get_foto(db, tipo: str, nome: str): + """Retorna (bytes_imagem, mimetype, updated_at) ou (None, None, None).""" + if not (tipo and nome): + return None, None, None + _ensure_foto_table(db) + row = db.execute( + text( + "SELECT imagem, mimetype, updated_at " + "FROM responsavel_foto WHERE tipo = :t AND nome = :n LIMIT 1" + ), + {"t": tipo, "n": nome}, + ).fetchone() + if row: + return row[0], (row[1] or "image/jpeg"), row[2] + return None, None, None + + +def _set_foto(db, tipo: str, nome: str, content: bytes, mimetype: str) -> None: + """Upsert simples por (tipo, nome).""" + if not (tipo and nome and content): + return + _ensure_foto_table(db) + upd = db.execute( + text( + "UPDATE responsavel_foto " + "SET imagem=:img, mimetype=:mt, updated_at=CURRENT_TIMESTAMP " + "WHERE tipo=:t AND nome=:n" + ), + {"img": content, "mt": mimetype or "image/jpeg", "t": tipo, "n": nome}, + ) + if upd.rowcount == 0: + db.execute( + text( + "INSERT INTO responsavel_foto (tipo, nome, imagem, mimetype) " + "VALUES (:t, :n, :img, :mt)" + ), + {"t": tipo, "n": nome, "img": content, "mt": mimetype or "image/jpeg"}, + ) + db.commit() + + +def _del_foto(db, tipo: str, nome: str) -> None: + if not (tipo and nome): + return + _ensure_foto_table(db) + db.execute(text("DELETE FROM responsavel_foto WHERE tipo=:t AND nome=:n"), {"t": tipo, "n": nome}) + db.commit() + + +# =============================== +# Estado +# =============================== +def limpar_estado_prod_esp(): + """Remove do session_state qualquer dado do módulo Produtividade_Especialista.""" + for key in list(st.session_state.keys()): + if key.startswith("prod_esp_"): + del st.session_state[key] + + +# =============================== +# UI – Gerenciar fotos de responsáveis +# =============================== +def _ui_fotos_responsaveis(df: pd.DataFrame): + """Bloco para cadastrar/atualizar/remover fotos de Especialistas e Conferentes.""" + st.subheader("📸 Fotos dos Responsáveis") + + especialistas = sorted([x for x in df["Especialista"].dropna().astype(str).unique() if x.strip()]) + conferentes = sorted([x for x in df["Conferente"].dropna().astype(str).unique() if x.strip()]) + + tab_esp, tab_conf = st.tabs(["Especialista", "Conferente"]) + + # ---------- Especialista ---------- + with tab_esp: + col_e1, col_e2 = st.columns([1, 2]) + with col_e1: + nome_esp = st.selectbox("Especialista", options=["(selecione)"] + especialistas, index=0, key="prod_esp_foto_esp_sel") + file_esp = st.file_uploader( + "Carregar foto (PNG/JPG/JPEG/GIF/WEBP) — Especialista", + type=["png", "jpg", "jpeg", "gif", "webp"], + key="prod_esp_foto_esp_up" + ) + salvar_esp = st.button("💾 Salvar/Atualizar foto (Especialista)", key="prod_esp_foto_esp_salvar") + remover_esp = st.button("🗑️ Remover foto (Especialista)", key="prod_esp_foto_esp_remover") + + with col_e2: + db = SessionLocal() + try: + if nome_esp and nome_esp != "(selecione)": + img_bytes, mt, updt = _get_foto(db, "especialista", nome_esp) + if img_bytes: + st.caption(f"Foto atual de **{nome_esp}** (atualizada em {updt})") + st.image(img_bytes, caption=nome_esp, use_container_width=False, width=220) + else: + st.info("Nenhuma foto cadastrada para este Especialista.") + finally: + db.close() + + if salvar_esp: + if not (nome_esp and nome_esp != "(selecione)"): + st.warning("Selecione um Especialista.") + elif not file_esp: + st.warning("Escolha um arquivo de imagem para enviar.") + else: + content = file_esp.read() + mt = file_esp.type or "image/jpeg" + db = SessionLocal() + try: + _set_foto(db, "especialista", nome_esp, content, mt) + st.success("Foto salva/atualizada com sucesso!") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar foto: {e}") + finally: + db.close() + + if remover_esp: + if not (nome_esp and nome_esp != "(selecione)"): + st.warning("Selecione um Especialista.") + else: + db = SessionLocal() + try: + _del_foto(db, "especialista", nome_esp) + st.info("Foto removida.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao remover foto: {e}") + finally: + db.close() + + # ---------- Conferente ---------- + with tab_conf: + col_c1, col_c2 = st.columns([1, 2]) + with col_c1: + nome_conf = st.selectbox("Conferente", options=["(selecione)"] + conferentes, index=0, key="prod_esp_foto_conf_sel") + file_conf = st.file_uploader( + "Carregar foto (PNG/JPG/JPEG/GIF/WEBP) — Conferente", + type=["png", "jpg", "jpeg", "gif", "webp"], + key="prod_esp_foto_conf_up" + ) + salvar_conf = st.button("💾 Salvar/Atualizar foto (Conferente)", key="prod_esp_foto_conf_salvar") + remover_conf = st.button("🗑️ Remover foto (Conferente)", key="prod_esp_foto_conf_remover") + + with col_c2: + db = SessionLocal() + try: + if nome_conf and nome_conf != "(selecione)": + img_bytes, mt, updt = _get_foto(db, "conferente", nome_conf) + if img_bytes: + st.caption(f"Foto atual de **{nome_conf}** (atualizada em {updt})") + st.image(img_bytes, caption=nome_conf, use_container_width=False, width=220) + else: + st.info("Nenhuma foto cadastrada para este Conferente.") + finally: + db.close() + + if salvar_conf: + if not (nome_conf and nome_conf != "(selecione)"): + st.warning("Selecione um Conferente.") + elif not file_conf: + st.warning("Escolha um arquivo de imagem para enviar.") + else: + content = file_conf.read() + mt = file_conf.type or "image/jpeg" + db = SessionLocal() + try: + _set_foto(db, "conferente", nome_conf, content, mt) + st.success("Foto salva/atualizada com sucesso!") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar foto: {e}") + finally: + db.close() + + if remover_conf: + if not (nome_conf and nome_conf != "(selecione)"): + st.warning("Selecione um Conferente.") + else: + db = SessionLocal() + try: + _del_foto(db, "conferente", nome_conf) + st.info("Foto removida.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao remover foto: {e}") + finally: + db.close() + + +# =============================== +# Mini-gráfico mensal (% acertos) — Helpers +# =============================== +def _normalize_responsaveis(df: pd.DataFrame) -> pd.DataFrame: + """Normaliza nomes (remove espaços/None) para evitar falhas de comparação.""" + for col in ["Especialista", "Conferente"]: + df[col] = df[col].astype(str).fillna("").str.strip() + df[col] = df[col].replace({"None": ""}) + return df + +def _month_labels_last_n(n: int) -> pd.DataFrame: + """Retorna DataFrame com os últimos n meses e rótulos MES/AA, em ordem cronológica.""" + base = pd.Timestamp(datetime.now().replace(day=1)) + months = [base - pd.DateOffset(months=i) for i in range(n-1, -1, -1)] + return pd.DataFrame({ + "YM": [pd.Period(m, freq="M") for m in months], + "mes": [m.strftime("%b/%y").upper() for m in months] + }) + +def _serie_pct_mensal(df: pd.DataFrame, resp_col: str, nome: str, months: int = 6) -> pd.DataFrame: + """ + Série mensal (últimos 'months' meses) de % acertos (MROB) para um responsável. + Retorna DataFrame com ['mes', 'pct', 'MROB', 'ERROS'] (meses sem dados => 0). + Corrige dtype para evitar TypeError: Expected numeric dtype, got object instead. + """ + if not (nome and resp_col in df.columns and "Data Coleta (dt)" in df.columns): + return pd.DataFrame(columns=["mes", "pct", "MROB", "ERROS"]) + + nome = str(nome).strip() + d = df[df[resp_col].astype(str).str.strip() == nome].copy() + d = d.dropna(subset=["Data Coleta (dt)"]) + + # Linha do tempo alvo (sempre haverá N meses) + base = _month_labels_last_n(months) + + # Se não há dados, devolve zeros + if d.empty: + base["MROB"] = 0.0 + base["ERROS"] = 0.0 + base["pct"] = 0.0 + return base[["mes", "pct", "MROB", "ERROS"]] + + d["YM"] = d["Data Coleta (dt)"].dt.to_period("M") + + g = ( + d.groupby("YM", as_index=False) + .agg(MROB=("Linhas MROB", "sum"), ERROS=("Linhas Erros MROB", "sum")) + ) + + # Merge garante a linha do tempo completa — aqui o dtype pode virar 'object' + m = base.merge(g, on="YM", how="left") + + # Coerção numérica robusta pós-merge (evita object -> round error) + m["MROB"] = pd.to_numeric(m["MROB"], errors="coerce").fillna(0).astype("float64") + m["ERROS"] = pd.to_numeric(m["ERROS"], errors="coerce").fillna(0).astype("float64") + + # % acertos (evita divisão por zero, resultado sempre float) + m["pct"] = np.where( + m["MROB"] > 0, + ((m["MROB"] - m["ERROS"]) / m["MROB"]) * 100.0, + 0.0 + ) + m["pct"] = pd.to_numeric(m["pct"], errors="coerce").fillna(0).astype("float64").round(2) + + # Seleciona e ordena colunas finais + out = m[["mes", "pct", "MROB", "ERROS"]].copy() + + # Garantia de dtype correto (evita regressões futuras) + out["MROB"] = out["MROB"].astype("float64") + out["ERROS"] = out["ERROS"].astype("float64") + out["pct"] = out["pct"].astype("float64") + + return out + +def _mini_grafico_pct_mensal(df_m: pd.DataFrame, meta: float, chart_type: str = "Linha", show_meta: bool = True, titulo: str = "% Acertos por mês"): + """ + Renderiza mini‑gráfico compacto (% acertos | 0–100) com fallback: + 1) Altair (linha/barras + meta) 2) Matplotlib 3) Tabela + """ + if df_m.empty: + st.caption("Sem dados mensais para o período/seleção atual.") + return + + # 1) ALTair + if ALT_AVAILABLE: + try: + base = alt.Chart(df_m).encode( + x=alt.X("mes:N", title="Mês"), + y=alt.Y("pct:Q", title="% Acertos", scale=alt.Scale(domain=[0, 100])), + tooltip=[ + alt.Tooltip("mes:N", title="Mês"), + alt.Tooltip("pct:Q", title="% Acertos (%)"), + alt.Tooltip("MROB:Q", title="MROB (Σ)"), + alt.Tooltip("ERROS:Q", title="Erros MROB (Σ)") + ] + ) + chart = base.mark_line(point=True, interpolate="monotone", color="#0d6efd") if chart_type == "Linha" \ + else base.mark_bar(size=18, color="#0d6efd") + + final = chart.properties(width=260, height=150, title=titulo) + + if show_meta: + meta_df = pd.DataFrame({"y": [meta]}) + meta_rule = alt.Chart(meta_df).mark_rule(color="#16a34a", strokeDash=[6, 4]).encode(y="y:Q") + final = final + meta_rule + + st.altair_chart(final, use_container_width=False) + return + except Exception as e: + st.info(f"Render ALTair indisponível, usando fallback (detalhe: {e})") + + # 2) Matplotlib fallback + try: + fig, ax = plt.subplots(figsize=(3.2, 1.6), dpi=150) + x = list(range(len(df_m["mes"]))) + if chart_type == "Linha": + ax.plot(x, df_m["pct"].values, marker="o", color="#0d6efd", linewidth=1.5) + else: + ax.bar(x, df_m["pct"].values, color="#0d6efd", width=0.6) + if show_meta: + ax.axhline(y=meta, color="#16a34a", linestyle="--", linewidth=1) + + ax.set_ylim(0, 100) + ax.set_xticks(x) + ax.set_xticklabels(df_m["mes"].tolist(), rotation=0, fontsize=7) + ax.set_yticks([0, 20, 40, 60, 80, 100]) + ax.set_title(titulo, fontsize=9) + ax.grid(alpha=0.15, axis="y") + + plt.tight_layout() + st.pyplot(fig, use_container_width=False) + plt.close(fig) + return + except Exception as e: + st.warning(f"Não foi possível renderizar o mini‑gráfico (fallback MPL): {e}") + + # 3) Último recurso + st.caption("Exibindo dados da série por impossibilidade de gráfico:") + st.dataframe(df_m, use_container_width=True) + + +# =============================== +# MAIN +# =============================== +def main(): + + # 🧹 LIMPA ESTADO AO ENTRAR + if not st.session_state.get("_prod_esp_inicializado"): + limpar_estado_prod_esp() + st.session_state["_prod_esp_inicializado"] = True + + st.title("🏆 Produtividade por Especialista e Conferente") + + # 🔧 CONTROLES NA SIDEBAR + with st.sidebar: + st.markdown("### 🔄 Atualização automática") + auto_on = st.checkbox("Ativar atualização automática", value=True, key="prod_esp_auto_on") + auto_interval_s = st.slider("Intervalo (segundos)", min_value=10, max_value=300, value=30, step=5, key="prod_esp_auto_int") + + if "prod_esp_auto_int_effective" not in st.session_state: + st.session_state["prod_esp_auto_int_effective"] = auto_interval_s + + if st.button("✅ Aplicar intervalo"): + st.session_state["prod_esp_auto_int_effective"] = auto_interval_s + st.success(f"Intervalo atualizado para {auto_interval_s}s") + st.rerun() + + intervalo_efetivo = st.session_state.get("prod_esp_auto_int_effective", auto_interval_s) + st.caption(f"⏲️ Intervalo atual: **{intervalo_efetivo}s**") + + st.markdown("---") + st.markdown("### 🎯 Metas e Série") + meta_pct_especialistas = st.number_input("Meta (% MROB/Geral) — Especialistas", min_value=0.0, max_value=100.0, value=98.8, step=0.5, key="prod_esp_meta_pct_esp") + meta_pct_conferentes = st.number_input("Meta (% MROB/Geral) — Conferentes", min_value=0.0, max_value=100.0, value=98.8, step=0.5, key="prod_esp_meta_pct_conf") + serie_meses = st.slider("Meses no mini‑gráfico", min_value=3, max_value=12, value=6, step=1, key="prod_esp_serie_meses") + tipo_grafico = st.selectbox("Tipo do mini‑gráfico", ["Linha", "Barras"], index=0, key="prod_esp_tipo_grafico") + linha_meta = st.checkbox("Mostrar linha de meta", value=True, key="prod_esp_show_meta") + + st.markdown("---") + last_dt = st.session_state.get("prod_esp_last_update_dt") + if last_dt: + last_str = last_dt.strftime("%d/%m/%Y %H:%M:%S") + st.caption(f"🕒 Última atualização: **{last_str}**") + delta = datetime.now() - last_dt + if delta < timedelta(minutes=1): + ago_str = f"{delta.seconds}s" + elif delta < timedelta(hours=1): + mins = delta.seconds // 60 + secs = delta.seconds % 60 + ago_str = f"{mins}min {secs}s" + else: + hours = delta.seconds // 3600 + mins = (delta.seconds % 3600) // 60 + ago_str = f"{hours}h {mins}min" + st.caption(f"⏱️ Atualizado há **{ago_str}**") + if auto_on: + try: + nxt = (datetime.now() + timedelta(seconds=intervalo_efetivo)).strftime("%d/%m/%Y %H:%M:%S") + st.caption(f"🔁 Próximo refresh: **{nxt}**") + except Exception: + pass + else: + st.caption("🕒 Última atualização: **—**") + + if auto_on: + st_autorefresh(interval=intervalo_efetivo * 1000, limit=None, key="prod_esp_autorefresh") + + db = SessionLocal() + try: + registros = db.query(Equipamento).all() + st.session_state["prod_esp_last_update_dt"] = datetime.now() + + if not registros: + st.info("Nenhum registro encontrado.") + return + + # ========== BASE DF ========== + df = pd.DataFrame([{ + "FPSO": getattr(r, "fpso", None), + "Data Coleta": getattr(r, "data_coleta", None), + "Modal": getattr(r, "modal", None), + "Especialista": getattr(r, "especialista", None), + "Conferente": getattr(r, "conferente", None), + "Linhas OSM": getattr(r, "linhas_osm", 0), + "Linhas MROB": getattr(r, "linhas_mrob", 0), + "Linhas Erros MROB": getattr(r, "linhas_erros_mrob", None), + "Linhas Erros (Genérico)": getattr(r, "linhas_erros", None), + } for r in registros]) + + # Conversão robusta de datas + df["Data Coleta (dt)"] = pd.to_datetime(df["Data Coleta"], errors="coerce", dayfirst=True) + if df["Data Coleta (dt)"].isna().all(): + # tenta novamente sem dayfirst + df["Data Coleta (dt)"] = pd.to_datetime(df["Data Coleta"], errors="coerce", dayfirst=False) + + # Tipos numéricos + for col in ["Linhas OSM", "Linhas MROB", "Linhas Erros MROB", "Linhas Erros (Genérico)"]: + if col in df.columns: + df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64") + + # Fallback de erros MROB + if ("Linhas Erros MROB" not in df.columns) or (df["Linhas Erros MROB"].sum() == 0 and df["Linhas Erros (Genérico)"].sum() > 0): + df["Linhas Erros MROB"] = df.get("Linhas Erros (Genérico)", pd.Series([0] * len(df))) + + # Normaliza nomes + df = _normalize_responsaveis(df) + + # ======== Fotos (cadastro/visualização) ======== + _ui_fotos_responsaveis(df) + + # ========== FILTROS ========== + st.subheader("🔎 Filtros") + col1, col2, col3 = st.columns(3) + with col1: + filtro_fpso = st.multiselect("FPSO", sorted(df["FPSO"].dropna().unique()), key="prod_esp_fpso") + with col2: + filtro_modal = st.multiselect("Modal", sorted(df["Modal"].dropna().unique()), key="prod_esp_modal") + with col3: + periodo = st.date_input("Período de Coleta", value=None, key="prod_esp_periodo") + + df_filt = df.copy() + if filtro_fpso: + df_filt = df_filt[df_filt["FPSO"].isin(filtro_fpso)] + if filtro_modal: + df_filt = df_filt[df_filt["Modal"].isin(filtro_modal)] + if isinstance(periodo, (list, tuple)) and len(periodo) == 2: + data_inicio, data_fim = periodo + if pd.notna(data_inicio): + df_filt = df_filt[df_filt["Data Coleta (dt)"] >= pd.to_datetime(data_inicio)] + if pd.notna(data_fim): + df_filt = df_filt[df_filt["Data Coleta (dt)"] <= pd.to_datetime(data_fim) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)] + + # ======== Mapeamentos por responsável ======== + fpsos_por_especialista = ( + df_filt.groupby("Especialista", dropna=False)["FPSO"] + .apply(lambda x: ", ".join(sorted(set(x.dropna())))) + .to_dict() + ) + fpsos_por_conferente = ( + df_filt.groupby("Conferente", dropna=False)["FPSO"] + .apply(lambda x: ", ".join(sorted(set(x.dropna())))) + .to_dict() + ) + + # ======== Agregações ======== + grp_esp = (df_filt.groupby("Especialista", dropna=False) + .agg({"Linhas OSM":"sum","Linhas MROB":"sum","Linhas Erros MROB":"sum"}) + .reset_index()) + grp_esp["FPSO Responsável"] = grp_esp["Especialista"].map(lambda e: fpsos_por_especialista.get(e, "")) + grp_esp["Especialista (FPSO)"] = grp_esp.apply( + lambda r: f"{r['Especialista']} ({r['FPSO Responsável']})" if r["FPSO Responsável"] else str(r["Especialista"]), axis=1) + grp_esp["Total de Erros (MROB - Erros MROB)"] = (grp_esp["Linhas MROB"] - grp_esp["Linhas Erros MROB"]).clip(lower=0) + + # ✅ Denominador numérico (float) para evitar dtype object + denom_mrob_esp = pd.to_numeric(grp_esp["Linhas MROB"], errors="coerce").replace(0, np.nan).astype("float64") + num_acertos_esp = pd.to_numeric(grp_esp["Linhas MROB"], errors="coerce") - pd.to_numeric(grp_esp["Linhas Erros MROB"], errors="coerce") + num_erros_esp = pd.to_numeric(grp_esp["Linhas Erros MROB"], errors="coerce") + + grp_esp["% Acertos (MROB)"] = (num_acertos_esp / denom_mrob_esp * 100.0).round(2) + grp_esp["% Erros (MROB)"] = (num_erros_esp / denom_mrob_esp * 100.0).round(2) + + grp_esp = grp_esp.sort_values(by="Linhas OSM", ascending=False) + grp_esp = grp_esp[[ + "Especialista (FPSO)","Especialista","FPSO Responsável", + "Linhas OSM","Linhas MROB","Linhas Erros MROB", + "Total de Erros (MROB - Erros MROB)","% Acertos (MROB)","% Erros (MROB)" + ]] + + grp_conf = (df_filt.groupby("Conferente", dropna=False) + .agg({"Linhas OSM":"sum","Linhas MROB":"sum","Linhas Erros MROB":"sum"}) + .reset_index()) + grp_conf["FPSO Responsável"] = grp_conf["Conferente"].map(lambda c: fpsos_por_conferente.get(c, "")) + grp_conf["Conferente (FPSO)"] = grp_conf.apply( + lambda r: f"{r['Conferente']} ({r['FPSO Responsável']})" if r["FPSO Responsável"] else str(r["Conferente"]), axis=1) + grp_conf["Total de Erros (MROB - Erros MROB)"] = (grp_conf["Linhas MROB"] - grp_conf["Linhas Erros MROB"]).clip(lower=0) + + # ✅ Denominador numérico (float) para evitar dtype object + denom_mrob_conf = pd.to_numeric(grp_conf["Linhas MROB"], errors="coerce").replace(0, np.nan).astype("float64") + num_acertos_conf = pd.to_numeric(grp_conf["Linhas MROB"], errors="coerce") - pd.to_numeric(grp_conf["Linhas Erros MROB"], errors="coerce") + num_erros_conf = pd.to_numeric(grp_conf["Linhas Erros MROB"], errors="coerce") + + grp_conf["% Acertos (MROB)"] = (num_acertos_conf / denom_mrob_conf * 100.0).round(2) + grp_conf["% Erros (MROB)"] = (num_erros_conf / denom_mrob_conf * 100.0).round(2) + + grp_conf = grp_conf.sort_values(by="Linhas OSM", ascending=False) + grp_conf = grp_conf[[ + "Conferente (FPSO)","Conferente","FPSO Responsável", + "Linhas OSM","Linhas MROB","Linhas Erros MROB", + "Total de Erros (MROB - Erros MROB)","% Acertos (MROB)","% Erros (MROB)" + ]] + + # ======== KPIs Gerais ======== + st.subheader("📈 KPIs (dados filtrados) — Geral (Todos)") + total_especialistas = grp_esp["Especialista"].nunique() + total_conferentes = grp_conf["Conferente"].nunique() + total_osm_geral = int(df_filt["Linhas OSM"].sum()) + total_mrob_geral = int(df_filt["Linhas MROB"].sum()) + total_erros_mrob_geral = int(df_filt["Linhas Erros MROB"].sum()) + total_acertos_mrob_geral = (total_mrob_geral - total_erros_mrob_geral) + pct_acertos_geral = round((total_acertos_mrob_geral / total_mrob_geral * 100), 2) if total_mrob_geral > 0 else 0.0 + pct_erros_geral = round((total_erros_mrob_geral / total_mrob_geral * 100), 2) if total_mrob_geral > 0 else 0.0 + + k1,k2,k3,k4,k5 = st.columns(5) + k1.metric("Especialistas", f"{total_especialistas}") + k2.metric("Conferentes", f"{total_conferentes}") + k3.metric("Linhas OSM (Σ)", f"{total_osm_geral:,}".replace(",", ".")) + k4.metric("Linhas MROB (Σ)", f"{total_mrob_geral:,}".replace(",", ".")) + color_geral = "#198754" if pct_acertos_geral >= meta_pct_especialistas else "#dc3545" + k5.metric("% Acertos (MROB/Geral)", f"{pct_acertos_geral}%") + # 🔧 HTML deve usar ..., não entidades <> + st.markdown( + f"Meta (Especialistas): {meta_pct_especialistas}% • " + f"{'✅ Dentro da meta' if pct_acertos_geral >= meta_pct_especialistas else '⚠️ Abaixo da meta'}", + unsafe_allow_html=True + ) + st.markdown(f"% Erros (MROB/Geral): {pct_erros_geral}%", unsafe_allow_html=True) + st.divider() + + # ======== KPIs por Especialista (foto + mini‑gráfico) ======== + st.subheader("🎯 KPIs por Especialista") + especialistas_lista = ["(selecione)"] + list(grp_esp["Especialista"].astype(str).unique()) + esp_sel = st.selectbox("Especialista:", especialistas_lista, index=0, key="prod_esp_kpi_esp") + + if esp_sel and esp_sel != "(selecione)": + linha_esp = grp_esp[grp_esp["Especialista"] == esp_sel] + if not linha_esp.empty: + le_osm = int(linha_esp["Linhas OSM"].iloc[0]) + le_mrob = int(linha_esp["Linhas MROB"].iloc[0]) + le_err_mrob = int(linha_esp["Linhas Erros MROB"].iloc[0]) + le_total_err = int(linha_esp["Total de Erros (MROB - Erros MROB)"].iloc[0]) + le_pct_acertos = float(linha_esp["% Acertos (MROB)"].iloc[0]) if pd.notna(linha_esp["% Acertos (MROB)"].iloc[0]) else 0.0 + le_pct_erros = float(linha_esp["% Erros (MROB)"].iloc[0]) if pd.notna(linha_esp["% Erros (MROB)"].iloc[0]) else 0.0 + + col_pic, col_chart, col_metrics = st.columns([1, 1.4, 3]) + with col_pic: + dbp = SessionLocal() + try: + img_b, mt, updt = _get_foto(dbp, "especialista", esp_sel) + if img_b: + st.image(img_b, caption=f"{esp_sel}", use_container_width=False, width=220) + else: + st.caption("Sem foto cadastrada.") + finally: + dbp.close() + with col_chart: + serie = _serie_pct_mensal(df_filt, "Especialista", esp_sel, months=serie_meses) + _mini_grafico_pct_mensal(serie, meta=meta_pct_especialistas, chart_type=tipo_grafico, show_meta=linha_meta) + with st.expander("🔧 Diagnóstico da série (Especialista)", expanded=False): + st.dataframe(serie, use_container_width=True) + with col_metrics: + s1,s2,s3,s4,s5 = st.columns(5) + s1.metric("Linhas OSM", f"{le_osm:,}".replace(",", ".")) + s2.metric("Linhas MROB", f"{le_mrob:,}".replace(",", ".")) + s3.metric("Erros MROB", f"{le_err_mrob:,}".replace(",", ".")) + s4.metric("Total Erros (MROB−Erros)", f"{le_total_err:,}".replace(",", ".")) + s5.metric("% Acertos (MROB)", f"{le_pct_acertos}%") + # 🔧 HTML deve usar ..., não entidades <> + st.markdown(f"% Erros (MROB): {le_pct_erros}%", unsafe_allow_html=True) + st.divider() + + # ======== KPIs por Conferente (foto + mini‑gráfico) ======== + st.subheader("🎯 KPIs por Conferente") + conferentes_lista = ["(selecione)"] + list(grp_conf["Conferente"].astype(str).unique()) + conf_sel = st.selectbox("Conferente:", conferentes_lista, index=0, key="prod_esp_kpi_conf") + + if conf_sel and conf_sel != "(selecione)": + linha_conf = grp_conf[grp_conf["Conferente"] == conf_sel] + if not linha_conf.empty: + lc_osm = int(linha_conf["Linhas OSM"].iloc[0]) + lc_mrob = int(linha_conf["Linhas MROB"].iloc[0]) + lc_err_mrob = int(linha_conf["Linhas Erros MROB"].iloc[0]) + lc_total_err = int(linha_conf["Total de Erros (MROB - Erros MROB)"].iloc[0]) + lc_pct_acertos = float(linha_conf["% Acertos (MROB)"].iloc[0]) if pd.notna(linha_conf["% Acertos (MROB)"].iloc[0]) else 0.0 + lc_pct_erros = float(linha_conf["% Erros (MROB)"].iloc[0]) if pd.notna(linha_conf["% Erros (MROB)"].iloc[0]) else 0.0 + + col_pic2, col_chart2, col_metrics2 = st.columns([1, 1.4, 3]) + with col_pic2: + dbp = SessionLocal() + try: + img_b, mt, updt = _get_foto(dbp, "conferente", conf_sel) + if img_b: + st.image(img_b, caption=f"{conf_sel}", use_container_width=False, width=220) + else: + st.caption("Sem foto cadastrada.") + finally: + dbp.close() + with col_chart2: + serie2 = _serie_pct_mensal(df_filt, "Conferente", conf_sel, months=serie_meses) + _mini_grafico_pct_mensal(serie2, meta=meta_pct_conferentes, chart_type=tipo_grafico, show_meta=linha_meta) + with st.expander("🔧 Diagnóstico da série (Conferente)", expanded=False): + st.dataframe(serie2, use_container_width=True) + with col_metrics2: + d1,d2,d3,d4,d5 = st.columns(5) + d1.metric("Linhas OSM", f"{lc_osm:,}".replace(",", ".")) + d2.metric("Linhas MROB", f"{lc_mrob:,}".replace(",", ".")) + d3.metric("Erros MROB", f"{lc_err_mrob:,}".replace(",", ".")) + d4.metric("Total Erros (MROB−Erros)", f"{lc_total_err:,}".replace(",", ".")) + d5.metric("% Acertos (MROB)", f"{lc_pct_acertos}%") + # 🔧 HTML deve usar ..., não entidades <> + st.markdown(f"% Erros (MROB): {lc_pct_erros}%", unsafe_allow_html=True) + + st.divider() + + # ======== Listas e Gráficos maiores ======== + st.subheader("🧾 Lista por Especialista (com métricas)") + st.dataframe(grp_esp, use_container_width=True) + + st.subheader("🧾 Lista por Conferente (com métricas)") + st.dataframe(grp_conf, use_container_width=True) + + st.subheader("📊 Gráficos") + try: + st.caption("Linhas OSM por Especialista (FPSO)") + st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas OSM"]) + st.caption("Linhas MROB por Especialista (FPSO)") + st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas MROB"]) + st.caption("Linhas de Erros MROB por Especialista (FPSO)") + st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas Erros MROB"]) + st.caption("Linhas OSM por Conferente (FPSO)") + st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas OSM"]) + st.caption("Linhas MROB por Conferente (FPSO)") + st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas MROB"]) + st.caption("Linhas de Erros MROB por Conferente (FPSO)") + st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas Erros MROB"]) + except Exception as e: + st.warning(f"Não foi possível renderizar alguns gráficos: {e}") + + st.divider() + + # ======== Exportação ======== + st.subheader("⬇️ Exportar") + buffer_esp = BytesIO() + with pd.ExcelWriter(buffer_esp, engine="openpyxl") as writer: + grp_esp.to_excel(writer, index=False, sheet_name="Prod_Especialista") + buffer_esp.seek(0) + st.download_button( + label="⬇️ Exportar produtividade por Especialista (Excel)", + data=buffer_esp, + file_name="produtividade_especialista.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + key="prod_esp_export" + ) + + buffer_conf = BytesIO() + with pd.ExcelWriter(buffer_conf, engine="openpyxl") as writer: + grp_conf.to_excel(writer, index=False, sheet_name="Prod_Conferente") + buffer_conf.seek(0) + st.download_button( + label="⬇️ Exportar produtividade por Conferente (Excel)", + data=buffer_conf, + file_name="produtividade_conferente.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + key="prod_conf_export" + ) + + finally: + db.close() + + + + + + + + diff --git a/add_pergunta.py b/add_pergunta.py new file mode 100644 index 0000000000000000000000000000000000000000..efbfb25bc2fde638e4cd7d16c9514f7c5376b41e --- /dev/null +++ b/add_pergunta.py @@ -0,0 +1,129 @@ +from banco import SessionLocal +from models import QuizPergunta, QuizResposta + +def adicionar_pergunta(pergunta_texto, respostas, correta_index): + db = SessionLocal() + try: + # Cria a pergunta + pergunta = QuizPergunta(pergunta=pergunta_texto) + db.add(pergunta) + db.commit() # Gera o ID da pergunta para usar nas respostas + db.refresh(pergunta) # Atualiza o objeto com o ID do banco + + # Cria as respostas vinculadas à pergunta + for i, texto in enumerate(respostas): + resposta = QuizResposta( + pergunta_id=pergunta.id, + texto=texto, + correta=(i == correta_index) + ) + db.add(resposta) + + db.commit() + print(f"Pergunta '{pergunta_texto}' adicionada com sucesso.") + except Exception as e: + db.rollback() + print(f"Erro ao adicionar pergunta: {e}") + finally: + db.close() + +if __name__ == "__main__": + adicionar_pergunta( + "O que significa FPSO?", + [ + "Floating Production Storage and Offloading", + "Fixed Production Storage Offshore", + "Floating Processing Supply Operation" + ], + 0 + ) + + adicionar_pergunta( + "Qual é a principal função de um FPSO?", + [ + "Armazenar contêineres", + "Produzir, armazenar e transferir petróleo", + "Transporte de passageiros" + ], + 1 + ) + + adicionar_pergunta( + "Onde normalmente um FPSO opera?", + [ + "Em portos comerciais", + "Em rios navegáveis", + "Em águas profundas e ultraprofundas" + ], + 2 + ) + + adicionar_pergunta( + "Qual produto NÃO é normalmente processado em um FPSO?", + [ + "Petróleo", + "Gás natural", + "Carvão mineral" + ], + 2 + ) + + adicionar_pergunta( + "Qual etapa vem após a produção do petróleo em um FPSO?", + [ + "Refino completo", + "Armazenamento e offloading", + "Transporte rodoviário" + ], + 1 + ) + + adicionar_pergunta( + "O que significa o termo 'offloading'?", + [ + "Processo de perfuração", + "Transferência de petróleo para navios aliviadores", + "Separação de óleo e gás" + ], + 1 + ) + + adicionar_pergunta( + "Qual profissional é mais associado à operação diária de um FPSO?", + [ + "Piloto de avião", + "Operador de produção offshore", + "Motorista de caminhão" + ], + 1 + ) + + adicionar_pergunta( + "Qual risco é mais comum em operações offshore?", + [ + "Congestionamento urbano", + "Derramamento de óleo", + "Falta de energia elétrica urbana" + ], + 1 + ) + + adicionar_pergunta( + "Por que FPSOs são preferidos em campos distantes da costa?", + [ + "Menor custo de construção", + "Dispensam oleodutos longos", + "Exigem menos tripulação" + ], + 1 + ) + + adicionar_pergunta( + "Qual é um requisito essencial de segurança em FPSOs?", + [ + "Plano de evacuação e emergência", + "Seguro veicular", + "Licença rodoviária" + ], + 0 + ) \ No newline at end of file diff --git a/administracao.py b/administracao.py new file mode 100644 index 0000000000000000000000000000000000000000..40da08c2563b8ad521007963d283b6c6e62c7b4d --- /dev/null +++ b/administracao.py @@ -0,0 +1,883 @@ + +# -*- coding: utf-8 -*- +import streamlit as st +from datetime import datetime, date +from banco import SessionLocal +from models import Equipamento +from log import registrar_log +from utils_fpso import campo_fpso +from utils_permissoes import verificar_permissao + +# 🔎 Utilitários SQLAlchemy para diagnóstico e migração simples +from sqlalchemy import inspect, text + +# ⬇️ Import seguro do modelo AvisoGlobal (não quebra se ainda não existir) +try: + from models import AvisoGlobal + _HAS_AVISO_GLOBAL = True +except Exception: + _HAS_AVISO_GLOBAL = False + +# ===================================================== +# LISTAS FIXAS +# ===================================================== +MODAL_LISTA = ["", "AÉREO", "MARÍTIMO", "EXPRESSO"] + + +# ===================================================== +# MENU INFO (DOCUMENTAÇÃO INTERNA DO SISTEMA) +# ===================================================== +def menu_info(): + + # ✅ Apêndice de documentação: novas funcionalidades e módulos (adicional) + doc_appendix() + + st.info("📌 Documentação interna do sistema. Acesso restrito a administradores.") + + + +# ===================================================== +# APÊNDICE DE DOCUMENTAÇÃO (NOVAS FUNCIONALIDADES) +# ===================================================== +def doc_appendix(): + """ + Adendo de documentação profissional que descreve + as novas funcionalidades, módulos e diretrizes sem + alterar o comportamento existente. + """ + st.divider() + st.subheader("📘 Atualizações e Diretrizes Profissionais") + + # ✅ NOVO: documentação padronizada do Módulo Formulário dentro do apêndice + with st.expander("🧾 Módulo Formulário (padrão)", expanded=False): + st.markdown(""" +**Objetivo** +Registrar, de forma padronizada, os dados operacionais de equipamentos (FPSO, Modal, OSM, MROB, métricas e administrativos), garantindo rastreabilidade e qualidade das informações. + +**Funcionalidades** +- Sugestões para **FPSO** e **FPSO1** via `campo_fpso` +- Campo controlado **“Outro”** quando aplicável +- Validação de **campos obrigatórios** (ex.: FPSO, Modal, OSM, MROB) +- Registro automático de **data/hora** (`data_hora_input`) +- Persistência completa em **banco de dados** (tabela `equipamentos`) +- **Auditoria**: ações de criação/edição/exclusão registradas + +**Campos Principais (Operacionais)** +- **FPSO / FPSO1**: identificação +- **Data de Coleta** +- **Especialista / Conferente / OSM** +- **Modal / Quantidade de Equipamentos / MROB** +- **Métricas**: Linhas OSM, Linhas MROB, Linhas com Erro +- **Erros**: Storekeeper, Operação WH, Especialista WH, Outros +- **Inclusão / Exclusão** (D1, D2, D3) + +**Dados Administrativos** +- **PO**, **Part Number**, **Material**, **Nota Fiscal** +- **Solicitante / Requisitante** +- **Impacto / Dimensão** +- **Motivo** (Inclusão/Exclusão) +- **Observações** (campo livre) + +**Validações** +- Checagem de obrigatoriedade em campos críticos +- Tratamento de valores ausentes (fallback seguro) +- Índices/sugestões pré-carregados (FPSO/Modal/OSM) + +**Fluxo de Dados** +1. Usuário preenche o formulário com apoio de listas/sugestões +2. Sistema valida campos e persiste em `equipamentos` +3. Ação administrativa é registrada em **auditoria** (`log_acesso`) +4. Registros editáveis posteriormente via **Administração de Registros** + +**Perfis / Permissões** +- Acesso controlado por **perfil** (admin / usuario / consulta) via `verificar_permissao` + +**Impacto** +- **Padronização** dos cadastros +- **Redução de erros** operacionais +- **Rastreabilidade** completa (auditoria + carimbo de data/hora) +""") + + with st.expander("📚 Estrutura de Módulos e Grupos (modules_map.py)", expanded=False): + st.markdown(""" +- **Grupos suportados**: Operação Load, Backload, Operação, Terceiros, BI. +- Cada módulo deve ter: `key`, `label`, `descricao`, `perfis`, `grupo`. +- O **menu lateral** exibe: `Pesquisar módulo` → `Selecione a operação (grupo)` → `Selecione o módulo`. +- Grupos **sem módulos** (ou sem permissão) exibem: _“Em desenvolvimento”_. +- **Boas práticas**: labels padronizados, `key` único (sem acentos e espaços), controle de acesso via `perfis`. +""") + + with st.expander("🧭 Navegação e UI (menu lateral)", expanded=False): + st.markdown(""" +- **Pesquisa**: filtra módulos pelo `label`. +- **Selectbox de Operação**: lista grupos disponíveis. +- **Selectbox de Módulo**: exibe módulos filtrados por grupo e permissões. +- **Rodapé da sidebar**: apresenta **e-mail do usuário logado** (badge alinhado) e bloco de **versão + desenvolvedor**. +- **Layout**: `st.set_page_config(layout="wide")` habilitado, área de conteúdo responsiva. +""") + + with st.expander("📧 E-mail do Usuário Logado (login + sidebar)", expanded=False): + st.markdown(""" +- `login.py` grava na sessão: `st.session_state.email` e `st.session_state.nome` (se disponíveis). +- Rodapé da sidebar exibe o e-mail em **formato badge** com ícone e alinhamento (`inline-flex`). +- Caso o e-mail não apareça: verifique se o usuário possui e-mail cadastrado e/ou revalide o login. +""") + + with st.expander("🧾 Auditoria com E-mail", expanded=False): + st.markdown(""" +- O módulo de auditoria realiza **JOIN** com `Usuario` e agora inclui **E-mail** na consulta. +- Exportação para Excel também leva a coluna **E-mail**. +- Observação: `JOIN` padrão é interno; para logs órfãos, use `outerjoin` (se necessário). +""") + + with st.expander("🛠️ Banco de Dados e Ferramentas (db_tools)", expanded=False): + st.markdown(""" +- Em **SQLite** e **PostgreSQL**, as alterações (ex.: adicionar `nome` e `email` em `usuarios`) podem ser aplicadas via módulo **`db_tools`** com `ALTER TABLE` e criação de índice único (`email`). +- **Atenção**: `Base.metadata.create_all()` **não migra** tabelas existentes; para mudanças de esquema use `ALTER TABLE`, **Alembic** (recomendado) ou recrie o banco (backup antes). +- **Verificação de colunas**: `PRAGMA table_info(usuarios)` (SQLite) ou `information_schema.columns` (Postgres/MySQL). +""") + + with st.expander("🎮 Jogos / Treinamento (módulo jogos)", expanded=False): + st.markdown(""" +- **Jogo da Forca (Treinamento)**: perguntas por categoria, avanço de nível, contagem de tentativas. +- **Caça ao Tesouro (Níveis)**: pistas Sim/Não com feedback visual e avanço até o limite de perguntas. +- **Dado (Curiosidades)**: número de lados configurável, curiosidades de FPSO/Estoque/Óleo e Gás. +- **Pontuação e balões**: opção de efeitos visuais e pontuação acumulada. +""") + + with st.expander("🧠 Quiz e Ranking", expanded=False): + st.markdown(""" +- **Quiz**: perguntas dinâmicas via banco; fluxo ajustável (sem limitadores) e com opção de **Voltar ao sistema**. +- **Ranking**: consolida pontuação por rodada/período e oferece exportação. +""") + + with st.expander("🎨 Diretrizes de Layout e Acessibilidade", expanded=False): + st.markdown(""" +- **Responsividade**: usar `use_container_width=True` em tabelas/gráficos. +- **Colunas fluidas**: `st.columns()` para KPIs (ajuste automático em telas menores). +- **Expansores**: `st.expander()` para reduzir poluição visual. +- **Temas**: arquivo `.streamlit/config.toml` pode definir `primaryColor`, `secondaryBackgroundColor`, etc. +""") + + with st.expander("🔐 Segurança e Boas Práticas", expanded=False): + st.markdown(""" +- **Senhas**: sempre criptografadas (ex.: `utils_seguranca`), nunca armazenar em texto claro. + - **Perfis**: `verificar_permissao(mod_id)` controla acesso; mantenha perfis atualizados. +- **Auditoria**: registrar ações administrativas via `registrar_log(...)`. +""") + + with st.expander("📦 Versionamento e Suporte", expanded=False): + st.markdown(""" +- **Versão atual**: exibida no rodapé da sidebar. +- **Desenvolvedor**: contato visível na sidebar | Rodrigo Silva. +- **Próximos passos**: documentação dos novos grupos/módulos, criação de migrations com Alembic, e manuais por equipe (Operação, Backload, Terceiros, BI). +""") + + +# ===================================================== +# 🔔 Aviso Global — helpers +# ===================================================== +def _get_db_session_admin(): + """ + Sessão ciente do ambiente atual (via db_router, quando disponível). + Fallback para SessionLocal(). + """ + try: + from db_router import get_session_for_current_db # ajuste o nome se necessário + return get_session_for_current_db() + except Exception: + return SessionLocal() + +def _sanitize_largura(largura_raw: str) -> str: + val = (largura_raw or "").strip() + if not val: + return "100%" + if val.endswith("%") or val.endswith("px"): + return val + if val.isdigit(): + return f"{val}px" + return "100%" + +def _obter_aviso_ativo_admin(): + if not _HAS_AVISO_GLOBAL: + return None + db = _get_db_session_admin() + try: + return ( + db.query(AvisoGlobal) + .filter(AvisoGlobal.ativo == True) + .order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc()) + .first() + ) + except Exception: + return None + finally: + try: + db.close() + except Exception: + pass + +# 🔧 Diagnóstico e correção de schema (colunas) da tabela aviso_global +def _verificar_schema_aviso_global(show_ui: bool = True) -> bool: + """ + Retorna True se o schema está OK (inclui font_size). + Se show_ui=True, exibe UI com botão para criar coluna ausente. + """ + if not _HAS_AVISO_GLOBAL: + if show_ui: + st.error("Modelo AvisoGlobal não encontrado.") + return False + + db = _get_db_session_admin() + try: + insp = inspect(db.bind) + cols = [c["name"] for c in insp.get_columns("aviso_global")] + falta_font = "font_size" not in cols + + if show_ui: + with st.expander("🧪 Diagnóstico do schema (aviso_global)", expanded=False): + st.caption("Colunas atuais: " + (", ".join(cols) if cols else "—")) + if falta_font: + st.warning("A coluna **font_size** não existe neste banco/ambiente.") + col_btn1, col_btn2 = st.columns([1, 3]) + if col_btn1.button("⚙️ Criar coluna font_size (DEFAULT 14)"): + try: + dialect = db.bind.dialect.name + if dialect == "sqlite": + sql = "ALTER TABLE aviso_global ADD COLUMN font_size INTEGER DEFAULT 14" + elif dialect == "postgresql": + sql = "ALTER TABLE aviso_global ADD COLUMN font_size integer DEFAULT 14" + elif dialect in ("mysql", "mariadb"): + sql = "ALTER TABLE aviso_global ADD COLUMN font_size INT DEFAULT 14" + else: + st.error(f"Dialeto não suportado para criação automática: {dialect}") + return False + db.execute(text(sql)) + db.commit() + st.success("Coluna 'font_size' criada com sucesso. Recarregando...") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao criar coluna: {e}") + else: + st.success("Schema OK ✔ (coluna 'font_size' presente).") + return not falta_font + + except Exception as e: + if show_ui: + st.error(f"Falha ao inspecionar o schema: {e}") + return False + finally: + try: + db.close() + except Exception: + pass + +def _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size) -> bool: + if not _HAS_AVISO_GLOBAL: + return False + db = _get_db_session_admin() + try: + # desativa os ativos + db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True).update({AvisoGlobal.ativo: False}) + novo = AvisoGlobal( + mensagem=(mensagem or "").strip(), + bg_color=(bg_color or "#FFF3CD").strip(), + text_color=(text_color or "#664D03").strip(), + largura=_sanitize_largura(largura), + efeito=efeito if efeito in ("marquee", "fixo") else "marquee", + velocidade=max(5, min(int(velocidade or 20), 120)), + ativo=True, + updated_at=datetime.now(), + ) + # salva font_size quando o atributo/coluna existir (fallback seguro) + try: + setattr(novo, "font_size", max(10, min(int(font_size or 14), 48))) + except Exception: + pass + + db.add(novo) + db.commit() + db.expire_all() + return True + except Exception as e: + db.rollback() + # Diagnóstico visível para o admin + st.error(f"Falha ao publicar o aviso: {e}") + try: + insp = inspect(db.bind) + cols = [c["name"] for c in insp.get_columns("aviso_global")] + st.caption("Colunas em aviso_global: " + ", ".join(cols)) + except Exception: + pass + return False + finally: + try: + db.close() + except Exception: + pass + +def _desativar_aviso_admin() -> bool: + if not _HAS_AVISO_GLOBAL: + return False + db = _get_db_session_admin() + try: + db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True)\ + .update({AvisoGlobal.ativo: False, AvisoGlobal.updated_at: datetime.now()}) + db.commit() + db.expire_all() + return True + except Exception: + db.rollback() + return False + finally: + try: + db.close() + except Exception: + pass + + +# =============================== +# 🔎 Pré-visualização do Aviso Global (somente render local) +# =============================== +def _render_preview_aviso_topbar(mensagem: str, bg_color: str, text_color: str, largura: str, efeito: str, velocidade: int, font_size: int): + largura = _sanitize_largura(largura) + bg = (bg_color or "#FFF3CD").strip() + fg = (text_color or "#664D03").strip() + efeito = (efeito or "marquee").lower() + try: + velocidade = int(velocidade or 20) + except Exception: + velocidade = 20 + try: + font_size = max(10, min(int(font_size or 14), 48)) + except Exception: + font_size = 14 + + st.markdown( + f""" + +
+
+ {mensagem} +
+
+ """, + unsafe_allow_html=True + ) + + +# ===================================================== +# 🔔 Menu: Aviso Global (Topo) +# ===================================================== +def menu_aviso_global(): + st.subheader("📣 Aviso Global (Topo)") + st.caption("Envie um aviso global exibido no topo para todos os usuários.") + + perfil = (st.session_state.get("perfil") or "usuario").strip().lower() + if perfil != "admin": + st.warning("Apenas administradores podem publicar avisos globais.") + return + + if not _HAS_AVISO_GLOBAL: + st.error( + "O modelo `AvisoGlobal` não foi encontrado em `models.py`." + ) + with st.expander("📄 Modelo necessário (copie para models.py)"): + st.code( + """from banco import Base +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text +from sqlalchemy.sql import func + +class AvisoGlobal(Base): + __tablename__ = "aviso_global" + id = Column(Integer, primary_key=True, index=True) + mensagem = Column(Text, nullable=False) + bg_color = Column(String(32), default="#FFF3CD") + text_color = Column(String(32), default="#664D03") + largura = Column(String(16), default="100%") + efeito = Column(String(16), default="marquee") + velocidade = Column(Integer, default=20) + font_size = Column(Integer, default=14) # tamanho da fonte (px) + ativo = Column(Boolean, default=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())""", + language="python", + ) + return + + # 🔎 Diagnóstico/migração simples do schema (font_size) + _verificar_schema_aviso_global(show_ui=True) + + aviso_atual = _obter_aviso_ativo_admin() + + msg_default = aviso_atual.mensagem if aviso_atual else "" + bg_default = aviso_atual.bg_color if aviso_atual else "#FFF3CD" + fg_default = aviso_atual.text_color if aviso_atual else "#664D03" + w_default = aviso_atual.largura if aviso_atual else "100%" + ef_default = (aviso_atual.efeito if aviso_atual else "marquee") + vel_default = int(aviso_atual.velocidade if aviso_atual else 20) + fs_default = int(getattr(aviso_atual, "font_size", 14)) if aviso_atual else 14 # ⬅️ NOVO + + mensagem = st.text_input("Mensagem do aviso:", value=msg_default, placeholder="Ex.: Manutenção hoje às 18h...") + colc1, colc2 = st.columns(2) + bg_color = colc1.color_picker("Cor de fundo", value=bg_default) + text_color = colc2.color_picker("Cor do texto", value=fg_default) + + colw1, colw2 = st.columns([2,1]) + largura = colw1.text_input("Largura (ex.: 100% ou 1200px)", value=w_default) + efeito = colw2.selectbox("Efeito", ["marquee", "fixo"], index=(0 if ef_default=="marquee" else 1)) + + colv1, colv2 = st.columns(2) + velocidade = colv1.slider("Velocidade (segundos por ciclo)", min_value=5, max_value=120, value=vel_default, step=1, help="Usado apenas no modo 'marquee'.") + font_size = colv2.slider("Tamanho da fonte (px)", min_value=10, max_value=48, value=fs_default, step=1) # ⬅️ NOVO + + # --- Pré-visualização ao vivo (sem salvar) --- + st.markdown("**Pré-visualização:**") + if (mensagem or "").strip(): + _render_preview_aviso_topbar(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size) + else: + st.info("Digite a mensagem para ver a pré-visualização aqui.") + + colb1, colb2, colb3 = st.columns(3) + publicar = colb1.button("📢 Publicar/Atualizar aviso") + desativar = colb2.button("🛑 Desativar aviso atual") + atualizar_preview = colb3.button("🔄 Atualizar prévia") + + # Botão opcional de refresh da prévia (não salva nada; rerenderiza a página). + if atualizar_preview: + st.rerun() + + if publicar: + if not (mensagem or "").strip(): + st.warning("Digite a mensagem do aviso.") + else: + ok = _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size) + if ok: + try: + registrar_log( + usuario=st.session_state.get("usuario"), + acao="PUBLICAR_AVISO_GLOBAL", + tabela="aviso_global", + registro_id=None + ) + except Exception: + pass + st.success("Aviso publicado/atualizado!") + st.rerun() + else: + st.error("Não foi possível publicar o aviso. Verifique o banco/logs.") + + if desativar: + ok = _desativar_aviso_admin() + if ok: + try: + registrar_log( + usuario=st.session_state.get("usuario"), + acao="DESATIVAR_AVISO_GLOBAL", + tabela="aviso_global", + registro_id=None + ) + except Exception: + pass + st.info("Aviso desativado.") + st.rerun() + else: + st.error("Não foi possível desativar o aviso.") + + +# ===================================================== +# ADMINISTRAÇÃO (variação com abas/tabs) +# ===================================================== +def main(): + + # ✅ Detecta se usuário é admin; abas administrativas aparecem apenas para admin. + is_admin = verificar_permissao("administracao") + + # Título conforme perfil + if is_admin: + st.title("🔒 Administração") + # Admin vê todas as abas + tab_editar, tab_aviso, tab_info = st.tabs([ + "✏️ Editar / Excluir Registros", + "📣 Aviso Global (Topo)", + "📘 Info do Sistema" + ]) + else: + st.title("✏️ Edição de Registros") + # Não-admin vê apenas a aba de edição + (tab_editar,) = st.tabs(["✏️ Editar Registros"]) + + # ===================================================== + # BLOCO: INFO DO SISTEMA (apenas admin) + # ===================================================== + if is_admin: + with tab_info: + menu_info() + + # ===================================================== + # BLOCO: AVISO GLOBAL (apenas admin) + # ===================================================== + with tab_aviso: + menu_aviso_global() + + # ===================================================== + # BLOCO: EDIÇÃO / EXCLUSÃO (excluir só admin) + # ===================================================== + with tab_editar: + + # ===================================================== + # FUNÇÃO UTILITÁRIA + # ===================================================== + def safe_index(lista, valor): + """Evita erro quando o valor salvo no banco não existe na lista""" + try: + return lista.index(valor) + except ValueError: + return 0 + + db = SessionLocal() + try: + # ===================================================== + # 🔎 FILTROS OPCIONAIS COM SUGESTÕES DO BANCO + # (disponível para todos os perfis) + # ===================================================== + st.subheader("🔎 Filtro de Busca (opcional)") + + # IMPORTANTE: usar .distinct() sobre a coluna, como já estava + fpsos = [""] + sorted({r.fpso for r in db.query(Equipamento.fpso).distinct() if r.fpso}) + modais = [""] + sorted({r.modal for r in db.query(Equipamento.modal).distinct() if r.modal}) + osms = [""] + sorted({r.osm for r in db.query(Equipamento.osm).distinct() if r.osm}) + # 🟩 NOVO: lista de Nota Fiscal para multiselect assistido + notas_dist = [""] + sorted({str(r.nota_fiscal) for r in db.query(Equipamento.nota_fiscal).distinct() if r.nota_fiscal}) + + col1, col2, col3, col4 = st.columns(4) + + with col1: + filtro_fpso = st.selectbox("FPSO", fpsos) + + with col2: + filtro_modal = st.selectbox("Modal", modais) + + with col3: + filtro_osm = st.selectbox("OSM", osms) + + with col4: + filtro_data = st.date_input("Data Coleta", value=None) + + # 🟩 NOVO: filtros de Nota Fiscal + opção de ver só duplicadas + st.markdown("**🧾 Filtro por Nota Fiscal**") + nf_col1, nf_col2, nf_col3 = st.columns([2, 2, 1.2]) + with nf_col1: + filtro_nf_text = st.text_input( + "Digite uma ou mais NFs (separadas por vírgula)", + value="" + ) + with nf_col2: + filtro_nf_multi = st.multiselect( + "Ou selecione", + options=[x for x in notas_dist if x != ""] + ) + with nf_col3: + mostrar_apenas_nf_duplicadas = st.checkbox( + "Somente duplicadas", + value=False + ) + + # ===================================================== + # QUERY BASE (COMPORTAMENTO ORIGINAL) + NOVO FILTRO NF + # ===================================================== + query = db.query(Equipamento) + + if filtro_fpso: + query = query.filter(Equipamento.fpso == filtro_fpso) + + if filtro_modal: + query = query.filter(Equipamento.modal == filtro_modal) + + if filtro_osm: + query = query.filter(Equipamento.osm == filtro_osm) + + if filtro_data: + query = query.filter(Equipamento.data_coleta == filtro_data) + + # 🟩 NOVO: aplica filtro de Nota Fiscal (tratando como string) + notas_escolhidas = set() + if filtro_nf_text.strip(): + partes = [p.strip() for p in filtro_nf_text.split(",") if p.strip()] + notas_escolhidas.update(partes) + if filtro_nf_multi: + notas_escolhidas.update([str(x).strip() for x in filtro_nf_multi if str(x).strip()]) + + if notas_escolhidas: + # Como a coluna é do tipo texto no modelo, filtramos por igualdade textual. + # Para outros dialetos/formatos numéricos, garantir cast adequado. + query = query.filter(Equipamento.nota_fiscal.in_(list(notas_escolhidas))) + + registros = query.order_by(Equipamento.id.desc()).all() + + if not registros: + st.info("Nenhum registro encontrado.") + return + + # ===================================================== + # 🧭 SINALIZAÇÃO DE NF DUPLICADA (no conjunto filtrado) + # ===================================================== + # Monta DF auxiliar só com campos relevantes para contagem de NF + import pandas as pd + df_aux = pd.DataFrame([{ + "ID": r.id, + "Nota Fiscal": ("" if r.nota_fiscal is None else str(r.nota_fiscal).strip()) + } for r in registros]) + + # Contagem de ocorrências por NF (string, ignorando vazias) + if not df_aux.empty: + contagem = df_aux.loc[df_aux["Nota Fiscal"] != "", "Nota Fiscal"].value_counts() + notas_duplicadas = contagem[contagem > 1] + else: + notas_duplicadas = pd.Series(dtype=int) + + # Aviso e expander com a lista das duplicadas + if len(notas_duplicadas.index) > 0: + total_ocorrencias = int(notas_duplicadas.sum()) + st.warning( + f"⚠️ Foram encontradas **{total_ocorrencias}** ocorrências em **{len(notas_duplicadas)}** " + f"números de Nota Fiscal duplicados no resultado filtrado." + ) + with st.expander("Ver lista de notas duplicadas"): + st.dataframe( + notas_duplicadas.rename("Ocorrências").reset_index().rename(columns={"index": "Nota Fiscal"}), + use_container_width=True + ) + + # Se marcado: mantém na lista apenas as duplicadas + if mostrar_apenas_nf_duplicadas: + set_dup = set(notas_duplicadas.index.tolist()) + registros = [r for r in registros if (r.nota_fiscal is not None and str(r.nota_fiscal).strip() in set_dup)] + + if not registros: + st.info("Nenhum registro duplicado após aplicar o filtro de 'Somente duplicadas'.") + return + + else: + if mostrar_apenas_nf_duplicadas: + st.info("Não há notas duplicadas no conjunto filtrado.") + return + + # ===================================================== + # SELECTBOX DE ESCOLHA E FORMULÁRIO + # ===================================================== + mapa = { + f"ID {r.id} | FPSO {r.fpso} | {r.modal} | {r.osm} | {r.data_coleta} | NF: {r.nota_fiscal or '—'}": r.id + for r in registros + } + + escolha = st.selectbox("Selecione o registro", list(mapa.keys())) + registro = db.get(Equipamento, mapa[escolha]) + + st.divider() + st.subheader("✏️ Editar Registro") + + # ===================================================== + # FORMULÁRIO COMPLETO (MESMO DO MÓDULO FORMULÁRIO) + # ===================================================== + with st.form("form_edicao"): + + # ================== DADOS OPERACIONAIS ================== + st.subheader("📦 Dados Operacionais") + + col1, col2, col3 = st.columns(3) + + with col1: + fpso1 = campo_fpso("FPSO1", registro.fpso1) + fpso = campo_fpso("FPSO", registro.fpso) + data_coleta = st.date_input("Data de Coleta", registro.data_coleta) + especialista = st.text_input("Especialista", registro.especialista or "") + conferente = st.text_input("Conferente", registro.conferente or "") + osm = st.text_input("OSM", registro.osm or "") + + with col2: + modal = st.selectbox( + "Modal", + MODAL_LISTA, + index=safe_index(MODAL_LISTA, registro.modal) + ) + quant_equip = st.number_input( + "Quantidade de Equipamentos", + min_value=0, + value=registro.quant_equip or 0 + ) + mrob = st.text_input("MROB", registro.mrob or "") + + with col3: + linhas_osm = st.number_input("Total de Linhas OSM", value=registro.linhas_osm or 0) + linhas_mrob = st.number_input("Total de Linhas MROB", value=registro.linhas_mrob or 0) + linhas_erros = st.number_input("Total de Linhas com Erro", value=registro.linhas_erros or 0) + + st.divider() + + # ================== ANÁLISE DE ERROS ================== + st.subheader("⚠️ Análise de Erros") + + op_sim_nao = ["", "Sim", "Não"] + + col_e1, col_e2, col_e3, col_e4 = st.columns(4) + + with col_e1: + erro_storekeeper = st.selectbox( + "Storekeeper", op_sim_nao, + index=safe_index(op_sim_nao, registro.erro_storekeeper) + ) + + with col_e2: + erro_operacao = st.selectbox( + "Operação WH", op_sim_nao, + index=safe_index(op_sim_nao, registro.erro_operacao) + ) + + with col_e3: + erro_especialista = st.selectbox( + "Especialista WH", op_sim_nao, + index=safe_index(op_sim_nao, registro.erro_especialista) + ) + + with col_e4: + erro_outros = st.selectbox( + "Outros", op_sim_nao, + index=safe_index(op_sim_nao, registro.erro_outros) + ) + + op_inc_exc = ["", "INCLUSÃO", "EXCLUSÃO"] + + inclusao_exclusao = st.selectbox( + "Inclusão / Exclusão", + op_inc_exc, + index=safe_index(op_inc_exc, registro.inclusao_exclusao) + ) + + st.divider() + + # ================== DADOS ADMINISTRATIVOS ================== + st.subheader("🧾 Dados Administrativos") + + col_a1, col_a2, col_a3 = st.columns(3) + + with col_a1: + po = st.text_input("PO", registro.po or "") + part_number = st.text_input("Part Number", registro.part_number or "") + + with col_a2: + material = st.text_input("Material", registro.material or "") + nota_fiscal = st.text_input("Nota Fiscal", registro.nota_fiscal or "") + + with col_a3: + solicitante = st.text_input("Solicitante", registro.solicitante or "") + requisitante = st.text_input("Requisitante", registro.requisitante or "") + + impacto = st.text_input("Impacto", registro.impacto or "") + dimensao = st.text_input("Dimensão", registro.dimensao or "") + + # ✅ AJUSTE: corrigido para 'motivo' + motivo = st.text_input("Motivo da Inclusão / Exclusão", registro.motivo or "") + + observacoes = st.text_area( + "Observações", + registro.observacoes or "", + height=120 + ) + + op_dia = ["", "D1", "D2", "D3"] + + dia_inclusao = st.selectbox( + "Dia de Inclusão (D)", + op_dia, + index=safe_index(op_dia, registro.dia_inclusao) + ) + + # ================== AÇÃO ================== + # 🔐 Apenas admin pode excluir + opcoes_acao = ["Salvar Alterações"] + (["Excluir Registro"] if is_admin else []) + acao = st.radio( + "Ação", + opcoes_acao, + horizontal=True + ) + + submit = st.form_submit_button("Confirmar") + + # ===================================================== + # AÇÕES + # ===================================================== + if submit: + + if acao == "Salvar Alterações": + # Atualiza todos os campos dinamicamente (exceto id) + for campo in registro.__table__.columns.keys(): + if campo != "id": + setattr(registro, campo, locals().get(campo, getattr(registro, campo))) + + registro.data_hora_input = datetime.now() + db.commit() + + try: + registrar_log( + usuario=st.session_state.get("usuario"), + acao="EDITAR", + tabela="equipamentos", + registro_id=registro.id + ) + except Exception: + pass + + st.success("✅ Registro atualizado com sucesso!") + st.rerun() + + elif acao == "Excluir Registro" and is_admin: + db.delete(registro) + db.commit() + + try: + registrar_log( + usuario=st.session_state.get("usuario"), + acao="EXCLUIR", + tabela="equipamentos", + registro_id=registro.id + ) + except Exception: + pass + + st.success("🗑️ Registro excluído com sucesso!") + st.rerun() + + finally: + try: + db.close() + except Exception: + pass + + diff --git a/app.py b/app.py new file mode 100644 index 0000000000000000000000000000000000000000..3107f481971d695ddb0671dcae3e03fbae620dc5 --- /dev/null +++ b/app.py @@ -0,0 +1,1015 @@ +# -*- coding: utf-8 -*- +import streamlit as st +from dotenv import load_dotenv +from datetime import date, datetime, time + +# ⬇️ Import correto das utils de operação +from utils_operacao import obter_grupos_disponiveis, obter_modulos_para_grupo + +# ✅ Usa toda a largura da página (chamar antes de qualquer outro st.*) +st.set_page_config(layout="wide") + +# Carrega variáveis de ambiente +load_dotenv() + +# =============================== +# IMPORTAÇÃO DOS MÓDULOS +# =============================== +import formulario +import consulta +import relatorio +import administracao +import quiz +import ranking +import quiz_admin +import usuarios_admin +import videos +import auditoria +import importar_excel +import calendario +import auditoria_cleanup +import jogos +import db_tools +import db_admin +import db_monitor +import operacao +import db_export_import +import resposta # 📬 Admin: Caixa de Entrada IOI‑RUN (módulo interno) +import outlook_relatorio +import repositorio_load +import Produtividade_Especialista as produtividade_especialista +import rnc +import rnc_listagem +import rnc_relatorio +import sugestoes_usuario # 💡 Usuário: Sugestões IOI‑RUN (módulo separado) +import repo_rnc +import recebimento + +from utils_info import INFO_CONTEUDO, INFO_MODULOS, INFO_MAP_PAGINA_ID +from login import login +from utils_permissoes import verificar_permissao +from utils_layout import exibir_logo +from modules_map import MODULES +from banco import engine, Base, SessionLocal +from models import QuizPontuacao +from models import IOIRunSugestao +from models import AvisoGlobal + +# Extras p/ sessões ativas +from uuid import uuid4 +from sqlalchemy import text, func, or_ + +# 🗄️ Banco ativo (Produção/Teste/Treinamento) +try: + from db_router import current_db_choice, bank_label + _HAS_ROUTER = True +except Exception: + _HAS_ROUTER = False + def current_db_choice() -> str: + return "prod" + def bank_label(choice: str) -> str: + return "🟢 Produção" if choice == "prod" else "🔴 Teste" + # ❌ REMOVIDO: não chamar nenhuma página ao importar/rodar o app principal + # if __name__ == "__main__": + # rnc.pagina() + +# =============================== +# RERUN por querystring (atalho ?rr=1) +# =============================== +def _get_query_params(): + """Compat: retorna query params como dict (Streamlit novo/antigo).""" + try: + # Streamlit >= 1.32 + return dict(st.query_params) + except Exception: + # Streamlit antigo (experimental) + try: + return dict(st.experimental_get_query_params()) + except Exception: + return {} + +def _set_query_params(new_params: dict): + """Compat: define query params (Streamlit novo/antigo).""" + try: + st.query_params = new_params # Streamlit >= 1.32 + except Exception: + try: + st.experimental_set_query_params(**new_params) + except Exception: + pass + +def _check_rerun_qs(pagina_atual: str = ""): + """ + Se a URL contiver rr=1 (ou true), força um rerun e limpa o parâmetro para evitar loop. + ✅ Não dispara quando estiver na página 'resposta' (Inbox Admin). + ✅ Consome apenas uma vez por sessão. + ✅ (PATCH) Também não dispara quando estiver em 'outlook_relatorio' para não interromper leitura COM. + """ + try: + if st.session_state.get("__qs_rr_consumed__", False): + return + # 🔒 Evita rr=1 em módulos sensíveis a rerun/refresh + # 🟩 AJUSTE: incluir 'formulario' para não aplicar rr=1 quando o formulário estiver ativo + if pagina_atual in ("resposta", "outlook_relatorio", "formulario"): + return # não aplicar rr=1 dentro destes módulos (evita 'piscar' e cancelamentos) + + params = _get_query_params() + rr_raw = params.get("rr", ["0"]) + rr = rr_raw[0] if isinstance(rr_raw, (list, tuple)) else str(rr_raw) + if str(rr).lower() in ("1", "true"): + new_params = {k: v for k, v in params.items() if k != "rr"} + _set_query_params(new_params) + st.session_state["__qs_rr_consumed__"] = True + st.rerun() + except Exception: + pass + +# ========================================= +# DB helper — sessão ciente do ambiente +# ========================================= +def _get_db_session(): + """Retorna uma sessão de banco consistente com o ambiente atual.""" + try: + from db_router import get_session_for_current_db + return get_session_for_current_db() + except Exception: + pass + + try: + from db_router import get_engine_for_current_db + from sqlalchemy.orm import sessionmaker + Eng = get_engine_for_current_db() + return sessionmaker(bind=Eng)() + except Exception: + pass + + return SessionLocal() + +# =============================== +# CONFIGURAÇÃO INICIAL +# =============================== +Base.metadata.create_all(bind=engine) + +def quiz_respondido_hoje(usuario: str) -> bool: + # ✅ Usar sessão ciente do ambiente + db = _get_db_session() + try: + inicio_dia = datetime.combine(date.today(), time.min) + return ( + db.query(QuizPontuacao) + .filter( + QuizPontuacao.usuario == usuario, + QuizPontuacao.data >= inicio_dia + ) + .first() + is not None + ) + finally: + try: + db.close() + except Exception: + pass + +# =============================== +# Sessões ativas (usuários logados agora) +# =============================== +_SESS_TTL_MIN = 5 # janela para considerar "online" + +def _get_session_id() -> str: + if "_sid" not in st.session_state: + st.session_state["_sid"] = f"{uuid4()}" + return st.session_state["_sid"] + +def _ensure_sessao_table(db) -> None: + """Cria a tabela sessao_web caso não exista (SQLite/Postgres/MySQL).""" + dialect = db.bind.dialect.name + if dialect == "sqlite": + db.execute(text(""" + CREATE TABLE IF NOT EXISTS sessao_web ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + usuario TEXT NOT NULL, + session_id TEXT NOT NULL UNIQUE, + last_seen TIMESTAMP NOT NULL, + ativo INTEGER NOT NULL DEFAULT 1 + ) + """)) + elif dialect in ("postgresql", "postgres"): + db.execute(text(""" + CREATE TABLE IF NOT EXISTS sessao_web ( + id SERIAL PRIMARY KEY, + usuario TEXT NOT NULL, + session_id TEXT NOT NULL UNIQUE, + last_seen TIMESTAMPTZ NOT NULL, + ativo BOOLEAN NOT NULL DEFAULT TRUE + ) + """)) + else: # mysql / mariadb + db.execute(text(""" + CREATE TABLE IF NOT EXISTS sessao_web ( + id INT AUTO_INCREMENT PRIMARY KEY, + usuario VARCHAR(255) NOT NULL, + session_id VARCHAR(255) NOT NULL UNIQUE, + last_seen TIMESTAMP NOT NULL, + ativo TINYINT(1) NOT NULL DEFAULT 1 + ) + """)) + db.commit() + +def _session_heartbeat(usuario: str) -> None: + """Atualiza/insere a sessão ativa do usuário com last_seen = now() e faz limpeza básica.""" + if not usuario: + return + db = _get_db_session() + try: + _ensure_sessao_table(db) + sid = _get_session_id() + now_sql = "CURRENT_TIMESTAMP" + + upd = db.execute( + text(f"UPDATE sessao_web SET last_seen = {now_sql}, ativo = 1 WHERE session_id = :sid"), + {"sid": sid} + ) + if upd.rowcount == 0: + db.execute( + text(f"INSERT INTO sessao_web (usuario, session_id, last_seen, ativo) " + f"VALUES (:usuario, :sid, {now_sql}, 1)"), + {"usuario": usuario, "sid": sid} + ) + + dialect = db.bind.dialect.name + if dialect in ("postgresql", "postgres"): + cleanup_sql = f"UPDATE sessao_web SET ativo = FALSE WHERE last_seen < (NOW() - INTERVAL '{_SESS_TTL_MIN * 2} minutes')" + elif dialect == "sqlite": + cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN * 2} minutes')" + else: + cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN * 2} MINUTE)" + db.execute(text(cleanup_sql)) + db.commit() + except Exception: + db.rollback() + finally: + try: + db.close() + except Exception: + pass + +def _get_active_users_count() -> int: + """Conta usuários distintos com last_seen dentro da janela (_SESS_TTL_MIN) e ativo=1.""" + db = _get_db_session() + try: + _ensure_sessao_table(db) + dialect = db.bind.dialect.name + if dialect in ("postgresql", "postgres"): + threshold = f"(NOW() - INTERVAL '{_SESS_TTL_MIN} minutes')" + elif dialect == "sqlite": + threshold = f"datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN} minutes')" + else: + threshold = f"DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN} MINUTE)" + res = db.execute( + text(f"SELECT COUNT(DISTINCT usuario) AS c FROM sessao_web WHERE ativo = 1 AND last_seen >= {threshold}") + ).fetchone() + return int(res[0] if res and res[0] is not None else 0) + except Exception: + return 0 + finally: + try: + db.close() + except Exception: + pass + +def _mark_session_inactive() -> None: + """Marca a sessão atual como inativa (chamar no logout).""" + sid = st.session_state.get("_sid") + if not sid: + return + db = _get_db_session() + try: + _ensure_sessao_table(db) + db.execute(text("UPDATE sessao_web SET ativo = 0 WHERE session_id = :sid"), {"sid": sid}) + db.commit() + except Exception: + db.rollback() + finally: + try: + db.close() + except Exception: + pass + +# =============================== +# Aviso Global — Util (leitura e sanitização) +# =============================== +def _sanitize_largura(largura_raw: str) -> str: + val = (largura_raw or "").strip() + if not val: + return "100%" + if val.endswith("%") or val.endswith("px"): + return val + if val.isdigit(): + return f"{val}px" + return "100%" + +def obter_aviso_ativo(db): + try: + aviso = ( + db.query(AvisoGlobal) + .filter(AvisoGlobal.ativo == True) + .order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc()) + .first() + ) + return aviso + except Exception: + return None + +# =============================== +# Aviso Global — Render do banner superior (robusto) +# =============================== +def _render_aviso_global_topbar(): + try: + db = _get_db_session() + except Exception as e: + st.sidebar.warning(f"Aviso desativado: sessão indisponível ({e})") + return + + aviso = None + try: + aviso = obter_aviso_ativo(db) + except Exception as e: + st.sidebar.warning(f"Aviso desativado: falha ao consultar ({e})") + aviso = None + finally: + try: + db.close() + except Exception: + pass + + if not aviso: + return + + try: + largura = _sanitize_largura(aviso.largura) + bg = aviso.bg_color or "#FFF3CD" + fg = aviso.text_color or "#664D03" + efeito = (aviso.efeito or "marquee").lower() + velocidade = int(aviso.velocidade or 20) + try: + font_size = max(10, min(int(getattr(aviso, "font_size", 14)), 48)) + except Exception: + font_size = 14 + + altura = 52 # px + + st.markdown( + f""" + + +
+
+ {aviso.mensagem} +
+
+""", + unsafe_allow_html=True + ) + except Exception as e: + st.sidebar.warning(f"Aviso desativado: erro de render ({e})") + return + +# =============================== +# Logout (utilitário) +# =============================== +def logout(): + """Finaliza a sessão do usuário, limpa estados e recarrega a aplicação.""" + _mark_session_inactive() # marca esta sessão como inativa + st.session_state.logado = False + st.session_state.usuario = None + st.session_state.perfil = None + st.session_state.nome = None + st.session_state.email = None + st.session_state.quiz_verificado = False + st.rerun() + +# =============================== +# 🎂 Banner/efeito de aniversário +# =============================== +def _show_birthday_banner_if_needed(): + if st.session_state.get("__show_birthday__"): + st.session_state["__show_birthday__"] = False + st.markdown( + """ + +
+
+
🎊
🎉
+
🎊
🎉
+
🎊
🎉
+
🎊
🎉
+
🎊
🎉
+
🎊
🎉
+
+
+ """, + unsafe_allow_html=True + ) + st.balloons() + nome = st.session_state.get("nome") or st.session_state.get("usuario") or "Usuário" + st.markdown( + f""" +
+
+ 🎉 Feliz Aniversário, {nome}! 🎉 +
+
+ """, + unsafe_allow_html=True + ) + COR_FRASE = "#0d6efd" + st.markdown( + f""" +
+
+ Desejamos a você muitas conquistas e bons embarques ao longo do ano! 💜 +
+
+ """, + unsafe_allow_html=True + ) + +# =============================== +# MAIN +# =============================== +def main(): + # Estados iniciais + if "logado" not in st.session_state: + st.session_state.logado = False + if "usuario" not in st.session_state: + st.session_state.usuario = None + if "quiz_verificado" not in st.session_state: + st.session_state.quiz_verificado = False + if "user_responses_viewed" not in st.session_state: + st.session_state.user_responses_viewed = False + if "nav_target" not in st.session_state: + st.session_state.nav_target = None + + # ✅ Estado do intervalo de autoatualização (padrão aumentado p/ 60s; 0 = desligado) + st.session_state.setdefault("__auto_refresh_interval_sec__", 60) + + # LOGIN + if not st.session_state.logado: + st.session_state.quiz_verificado = False + exibir_logo(top=True, sidebar=False) + login() + return + + # 👥 Heartbeat + Badge de usuários logados (APENAS ADMIN) + _session_heartbeat(st.session_state.usuario) + if (st.session_state.get("perfil") or "").strip().lower() == "admin": + try: + online_now = _get_active_users_count() + except Exception: + online_now = 0 + st.sidebar.markdown( + f""" +
+ 🟢 Online (últimos {_SESS_TTL_MIN} min)
+ {online_now} +
+ """, + unsafe_allow_html=True + ) + + # 🔄 Botão de Recarregar (mantém a sessão ativa) + ⏱️ Controle do intervalo + st.sidebar.markdown("---") + # Linha com botão de recarregar e popover para o intervalo + col_reload, col_interval = st.sidebar.columns([1, 1]) + if col_reload.button("🔄 Recarregar (sem sair)", key="__btn_reload_now__"): + st.rerun() + + # Popover (se disponível) para configurar intervalo; fallback para expander + if hasattr(st, "popover"): + with col_interval.popover("⏱️ Autoatualização"): + new_val = st.number_input( + "Intervalo (segundos) — 0 desativa", + min_value=0, max_value=3600, + value=int(st.session_state["__auto_refresh_interval_sec__"]), + step=5, key="__auto_refresh_input__" + ) + if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"): + st.session_state["__auto_refresh_interval_sec__"] = int(new_val) + try: + if int(new_val) > 0: + st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️") + else: + st.toast("Autoatualização desativada.", icon="⛔") + except Exception: + pass + st.rerun() + else: + with st.sidebar.expander("⏱️ Autoatualização", expanded=False): + new_val = st.number_input( + "Intervalo (segundos) — 0 desativa", + min_value=0, max_value=3600, + value=int(st.session_state["__auto_refresh_interval_sec__"]), + step=5, key="__auto_refresh_input__" + ) + if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"): + st.session_state["__auto_refresh_interval_sec__"] = int(new_val) + try: + if int(new_val) > 0: + st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️") + else: + st.toast("Autoatualização desativada.", icon="⛔") + except Exception: + pass + st.rerun() + + usuario = st.session_state.usuario + perfil = (st.session_state.get("perfil") or "usuario").strip().lower() + + # QUIZ + if not st.session_state.quiz_verificado: + if not quiz_respondido_hoje(usuario): + exibir_logo(top=True, sidebar=False) + quiz.main() + return + else: + st.session_state.quiz_verificado = True + st.rerun() + + # SISTEMA LIBERADO + exibir_logo(top=True, sidebar=True) + _render_aviso_global_topbar() + _show_birthday_banner_if_needed() + + st.sidebar.markdown("### Menu | 🎉 Carnaval 🎭 2026!") + + # Banco ativo na sidebar + try: + banco_label = bank_label(current_db_choice()) if _HAS_ROUTER else ( + "🟢 Produção" if current_db_choice() == "prod" else "🔴 Teste" + ) + st.sidebar.caption(f"🗄️ Banco ativo: {banco_label}") + except Exception: + pass + + # ========================= + # Notificações no sidebar + # ========================= + + # --- Admin: pendentes --- + if perfil == "admin": + try: + db = _get_db_session() + pendentes = db.query(IOIRunSugestao).filter(func.lower(IOIRunSugestao.status) == "pendente").count() + except Exception: + pendentes = 0 + finally: + try: db.close() + except Exception: pass + + if pendentes > 0: + st.sidebar.markdown( + """ +
+ 🔔 {pendentes} sugestão(ões) pendente(s)
+ Acesse a caixa de entrada para responder. +
+ """.format(pendentes=pendentes), + unsafe_allow_html=True + ) + + # 👉 Direciona para o MESMO módulo do menu (resposta.main()) + if st.sidebar.button("📬 Abrir Caixa de Entrada (Admin)"): + st.session_state.nav_target = "resposta" + st.rerun() + + # --- Usuário: respostas novas (após último 'visto') --- + if perfil != "admin": + # Última vez que o usuário realmente abriu e visualizou as respostas + last_seen_dt = st.session_state.get("__user_last_answer_seen__") + + try: + db = _get_db_session() + + # Qual é a resposta mais recente existente + last_answer_dt_row = ( + db.query(IOIRunSugestao.data_resposta) + .filter( + IOIRunSugestao.usuario == usuario, + func.lower(IOIRunSugestao.status) == "respondida", + IOIRunSugestao.data_resposta != None + ) + .order_by(IOIRunSugestao.data_resposta.desc()) + .first() + ) + last_answer_dt = last_answer_dt_row[0] if last_answer_dt_row else None + + # Se há algo mais novo do que o 'visto', marcamos como não visto + if last_answer_dt and (not last_seen_dt or last_answer_dt > last_seen_dt): + st.session_state.user_responses_viewed = False + + # ✅ Conta SOMENTE respostas novas (depois do 'last_seen_dt') + novas_respostas = ( + db.query(IOIRunSugestao) + .filter( + IOIRunSugestao.usuario == usuario, + func.lower(IOIRunSugestao.status) == "respondida", + (IOIRunSugestao.data_resposta > last_seen_dt) if last_seen_dt else (IOIRunSugestao.data_resposta != None) + ) + .count() + ) + except Exception: + novas_respostas = 0 + finally: + try: db.close() + except Exception: pass + + # ✅ Exibir card de nova mensagem até o usuário clicar em "Ver respostas" + if novas_respostas > 0 and not st.session_state.get("user_responses_viewed", False): + st.sidebar.markdown( + """ +
+ 🔔 {resps} resposta(s) nova(s) para suas sugestões
+ Clique para ver suas respostas. +
+ """.format(resps=novas_respostas), + unsafe_allow_html=True + ) + + # (Opcional) Toast discreto — aparece uma única vez por sessão enquanto houver novidade + if not st.session_state.get("__user_toast_shown__"): + try: + st.toast("Você tem novas respostas do IOI‑RUN. Clique em '📥 Ver respostas'.", icon="💬") + except Exception: + pass + st.session_state["__user_toast_shown__"] = True + + if st.sidebar.button("📥 Ver respostas"): + # Não atualizamos last_seen aqui; isso é feito dentro do módulo do usuário + st.session_state.nav_target = "sugestoes_ioirun" + st.session_state.user_responses_viewed = True + st.rerun() + else: + # Se não há novidades, libera o toast para a próxima vez que houver + st.session_state["__user_toast_shown__"] = False + + # ------------------------- Menu lateral ------------------------- + termo_busca = st.sidebar.text_input("Pesquisar módulo:").strip().lower() + + try: + ambiente_atual = current_db_choice() if _HAS_ROUTER else "prod" + except Exception: + ambiente_atual = "prod" + + grupos_disponiveis = obter_grupos_disponiveis( + MODULES, + perfil=st.session_state.get("perfil", "usuario"), + usuario=st.session_state.get("usuario"), + ambiente=ambiente_atual, + verificar_permissao=verificar_permissao + ) + + if not grupos_disponiveis: + st.sidebar.selectbox("Selecione a operação:", ["Em desenvolvimento"], index=0) + st.warning("Nenhuma operação disponível para seu perfil/ambiente neste momento.") + return + + grupo_escolhido = st.sidebar.selectbox("Selecione a operação:", grupos_disponiveis) + + opcoes = obter_modulos_para_grupo( + MODULES, grupo_escolhido, termo_busca, + perfil=st.session_state.get("perfil", "usuario"), + usuario=st.session_state.get("usuario"), + ambiente=ambiente_atual, + verificar_permissao=verificar_permissao + ) + + with st.sidebar.expander("🔧 Diagnóstico do menu", expanded=False): + st.caption(f"Perfil: **{st.session_state.get('perfil', '—')}** | Grupo: **{grupo_escolhido}** | Busca: **{termo_busca or '∅'}** | Ambiente: **{ambiente_atual}**") + try: + mods_dbg = [{"id": mid, "label": lbl} for mid, lbl in (opcoes or [])] + except Exception: + mods_dbg = [] + st.write("Módulos visíveis (após regras):", mods_dbg if mods_dbg else "—") + + # Failsafe outlook_relatorio + try: + mod_outlook = MODULES.get("outlook_relatorio") + if mod_outlook: + mesmo_grupo = (mod_outlook.get("grupo") == grupo_escolhido) + perfil_ok = verificar_permissao( + perfil=st.session_state.get("perfil", "usuario"), + modulo_key="outlook_relatorio", + usuario=st.session_state.get("usuario"), + ambiente=ambiente_atual + ) + ja_nas_opcoes = any(mid == "outlook_relatorio" for mid, _ in (opcoes or [])) + passa_busca = (not termo_busca) or (termo_busca in mod_outlook.get("label", "").strip().lower()) + if mesmo_grupo and perfil_ok and not ja_nas_opcoes and passa_busca: + opcoes = (opcoes or []) + [("outlook_relatorio", mod_outlook.get("label", "Relatorio portaria"))] + except Exception: + pass + + # Failsafe repositorio_load + try: + mod_repo = MODULES.get("repositorio_load") + if mod_repo: + mesmo_grupo_r = (mod_repo.get("grupo") == grupo_escolhido) + perfil_ok_r = verificar_permissao( + perfil=st.session_state.get("perfil", "usuario"), + modulo_key="repositorio_load", + usuario=st.session_state.get("usuario"), + ambiente=ambiente_atual + ) + ja_nas_opcoes_r = any(mid == "repositorio_load" for mid, _ in (opcoes or [])) + passa_busca_r = (not termo_busca) or (termo_busca in mod_repo.get("label", "").strip().lower()) + if mesmo_grupo_r and perfil_ok_r and not ja_nas_opcoes_r and passa_busca_r: + opcoes = (opcoes or []) + [("repositorio_load", mod_repo.get("label", "Repositório Load"))] + except Exception: + pass + + if not opcoes: + st.sidebar.selectbox("Selecione o módulo:", ["Em desenvolvimento"], index=0) + st.warning(f"A operação '{grupo_escolhido}' está em desenvolvimento.") + return + + # ============================================================ + # 🔒 Fix: selectbox com 'key' + seleção forçada para 'resposta' + # quando vier de nav_target (sidebar) ou quando já estivermos na página. + # ============================================================ + labels = [label for _, label in opcoes] + + # Se foi solicitado nav_target, injeta a label alvo antes do selectbox + if st.session_state.get("nav_target"): + target = st.session_state["nav_target"] + try: + target_label = next(lbl for mid, lbl in opcoes if mid == target) + st.session_state["mod_select_label"] = target_label + except StopIteration: + pass + + # Inicializa/persiste seleção + if "mod_select_label" not in st.session_state or st.session_state["mod_select_label"] not in labels: + st.session_state["mod_select_label"] = labels[0] + + escolha_label = st.sidebar.selectbox( + "Selecione o módulo:", + labels, + index=labels.index(st.session_state["mod_select_label"]), + key="mod_select_label" + ) + + pagina_id = next(mod_id for mod_id, label in opcoes if label == escolha_label) + + # ✅ Navegação com lock (evita disputa com outros reruns) + if st.session_state.get("nav_target"): + pagina_id = st.session_state.nav_target + st.session_state["__nav_lock__"] = True + else: + st.session_state["__nav_lock__"] = False + + # 🔎 Agora que sabemos a página atual, tratamos rr=1 com segurança + _check_rerun_qs(pagina_atual=pagina_id) + + # ⏱️ Auto-refresh leve do sidebar — NÃO quando em Inbox/Admin/Outlook/Formulário/Recebimento + try: + from streamlit_autorefresh import st_autorefresh + is_inbox_admin = (pagina_id == "resposta") + is_outlook_rel = (pagina_id == "outlook_relatorio") + is_formulario = (pagina_id == "formulario") + is_recebimento = (pagina_id == "recebimento") + interval_sec = int(st.session_state.get("__auto_refresh_interval_sec__", 60)) + if (interval_sec > 0) and not (is_inbox_admin or is_outlook_rel or is_formulario or is_recebimento): + # key dinâmica por intervalo evita conflitos ao trocar o valor + st_autorefresh(interval=interval_sec * 1000, key=f"sidebar_autorefresh_{interval_sec}s") + except Exception: + pass + + # Logout + st.sidebar.markdown("---") + if st.session_state.get("logado"): + if st.sidebar.button("🚪 Sair (Logout)"): + logout() + + st.divider() + + # ------------------------- Roteamento ------------------------- + if pagina_id == "formulario": + formulario.main() + elif pagina_id == "consulta": + consulta.main() + elif pagina_id == "relatorio": + relatorio.main() + elif pagina_id == "ranking": + ranking.main() + elif pagina_id == "quiz": + quiz.main() + ranking.main() + elif pagina_id == "quiz_admin": + quiz_admin.main() + elif pagina_id == "usuarios": + usuarios_admin.main() + elif pagina_id == "administracao": + administracao.main() + elif pagina_id == "videos": + videos.main() + elif pagina_id == "auditoria": + auditoria.main() + elif pagina_id == "auditoria_cleanup": + auditoria_cleanup.main() + elif pagina_id == "importacao": + importar_excel.main() + elif pagina_id == "calendario": + calendario.main() + elif pagina_id == "jogos": + st.session_state.setdefault("pontuacao", 0) + st.session_state.setdefault("rodadas", 0) + st.session_state.setdefault("ultimo_resultado", None) + jogos.main() + elif pagina_id == "temporario": + db_tools.main() + elif pagina_id == "db_admin": + db_admin.main() + elif pagina_id == "db_monitor": + db_monitor.main() + elif pagina_id == "operacao": + operacao.main() + elif pagina_id == "resposta": # 📬 Admin + resposta.main() + elif pagina_id == "db_export_import": + db_export_import.main() + elif pagina_id == "produtividade_especialista": + produtividade_especialista.main() + elif pagina_id == "outlook_relatorio": + outlook_relatorio.main() + elif pagina_id == "sugestoes_ioirun": # 💡 Usuário + if st.session_state.get("perfil") == "admin": + st.info("Use a **📬 Caixa de Entrada (Admin)** para responder sugestões.") + else: + sugestoes_usuario.main() + elif pagina_id == "repositorio_load": + repositorio_load.main() + elif pagina_id == "rnc": + rnc.pagina() + elif pagina_id == "rnc_listagem": + rnc_listagem.pagina() + elif pagina_id == "rnc_relatorio": + rnc_relatorio.pagina() + elif pagina_id == "repo_rnc": + repo_rnc.pagina() + elif pagina_id == "recebimento": + recebimento.main() + + # ------------------------------------------------------ + # ℹ️ INFO — Guia passo a passo de uso (no sidebar) + # ------------------------------------------------------ + info_mod_default = INFO_MAP_PAGINA_ID.get(pagina_id, "Geral") + with st.sidebar.expander("ℹ️ Info • Como usar o sistema", expanded=False): + st.markdown(""" +**Bem-vindo!** +Este painel reúne instruções rápidas para utilizar cada módulo e seus campos. +Selecione o módulo abaixo ou navegue pelo menu — o conteúdo ajusta automaticamente. +""") + mod_info_sel = st.selectbox( + "Escolha o módulo para ver instruções:", + INFO_MODULOS, + index=INFO_MODULOS.index(info_mod_default) if info_mod_default in INFO_MODULOS else 0, + key="info_mod_sel" + ) + st.markdown(INFO_CONTEUDO.get(mod_info_sel, "_Conteúdo não disponível para este módulo._")) + + # ✅ Libera o nav_target após a 1ª render da página de destino + if st.session_state.get("__nav_lock__"): + st.session_state["nav_target"] = None + st.session_state["__nav_lock__"] = False + +if __name__ == "__main__": + main() + # ------------------------- + # Desenvolvedor e versão + # ------------------------- + if st.session_state.get("logado") and st.session_state.get("email"): + st.sidebar.markdown( + f""" +
+ 👤 + {st.session_state.email} +
+ """, + unsafe_allow_html=True + ) + + st.sidebar.markdown( + """ +
+

+ Versão: 1.0.0 • Desenvolvedor: Rodrigo Silva - Ideiasystem | 2026 +

+ """, + unsafe_allow_html=True + ) + + + + + + + + + + + + + diff --git a/app_outlook.py b/app_outlook.py new file mode 100644 index 0000000000000000000000000000000000000000..fa1a6f39bb875d2f2999708b66abd2207556d50f --- /dev/null +++ b/app_outlook.py @@ -0,0 +1,315 @@ + +# -*- coding: utf-8 -*- +import streamlit as st +import pandas as pd +from datetime import datetime, timedelta, date +import io +import pythoncom # ✅ necessário para inicializar/finalizar COM em cada operação + +st.set_page_config(page_title="Relatório de E-mails • Outlook Desktop", layout="wide") + + +# ============================== +# Utilitários de exportação/indicadores +# ============================== +def build_downloads(df: pd.DataFrame, base_name: str): + """Cria botões de download (CSV, Excel e PDF) para o DataFrame.""" + if df.empty: + st.warning("Nenhum dado para exportar.") + return + + # CSV + csv_buf = io.StringIO() + df.to_csv(csv_buf, index=False, encoding="utf-8-sig") + st.download_button( + "⬇️ Baixar CSV", + data=csv_buf.getvalue(), + file_name=f"{base_name}.csv", + mime="text/csv", + ) + + # Excel + xlsx_buf = io.BytesIO() + with pd.ExcelWriter(xlsx_buf, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Relatorio") + xlsx_buf.seek(0) + st.download_button( + "⬇️ Baixar Excel", + data=xlsx_buf, + file_name=f"{base_name}.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + # PDF (resumo com até 100 linhas para leitura confortável) + try: + from reportlab.lib.pagesizes import A4, landscape + from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer + from reportlab.lib import colors + from reportlab.lib.styles import getSampleStyleSheet + + pdf_buf = io.BytesIO() + doc = SimpleDocTemplate( + pdf_buf, + pagesize=landscape(A4), + rightMargin=20, leftMargin=20, topMargin=20, bottomMargin=20 + ) + styles = getSampleStyleSheet() + story = [] + + title = Paragraph(f"Relatório de E-mails — {base_name}", styles["Title"]) + story.append(title) + story.append(Spacer(1, 12)) + + # Limita tabela para evitar PDFs gigantes + df_show = df.copy().head(100) + data_table = [list(df_show.columns)] + df_show.astype(str).values.tolist() + table = Table(data_table, repeatRows=1) + table.setStyle(TableStyle([ + ("BACKGROUND", (0,0), (-1,0), colors.HexColor("#E9ECEF")), + ("TEXTCOLOR", (0,0), (-1,0), colors.HexColor("#212529")), + ("GRID", (0,0), (-1,-1), 0.25, colors.HexColor("#ADB5BD")), + ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"), + ("FONTNAME", (0,1), (-1,-1), "Helvetica"), + ("FONTSIZE", (0,0), (-1,-1), 9), + ("ALIGN", (0,0), (-1,-1), "LEFT"), + ("VALIGN", (0,0), (-1,-1), "MIDDLE"), + ])) + story.append(table) + + doc.build(story) + pdf_buf.seek(0) + + st.download_button( + "⬇️ Baixar PDF", + data=pdf_buf, + file_name=f"{base_name}.pdf", + mime="application/pdf", + ) + except Exception as e: + st.info(f"PDF: não foi possível gerar o arquivo (ReportLab). Detalhe: {e}") + + +def render_indicators(df: pd.DataFrame, dt_col_name: str): + """Exibe indicadores simples (top remetentes, distribuição por dia).""" + if df.empty: + return + st.subheader("📊 Indicadores") + col1, col2 = st.columns(2) + with col1: + st.write("**Top Remetentes (Top 10)**") + st.dataframe( + df["Remetente"].value_counts().head(10).rename("Qtd").to_frame(), + use_container_width=True, + ) + with col2: + st.write("**Mensagens por Dia**") + if dt_col_name in df.columns: + _dt = pd.to_datetime(df[dt_col_name], errors="coerce") + por_dia = _dt.dt.date.value_counts().sort_index().rename("Qtd") + st.dataframe(por_dia.to_frame(), use_container_width=True) + + +# ============================== +# Outlook Desktop (Windows) — COM-safe helpers +# ============================== +def _list_folders_desktop(root_folder, prefix=""): + """Recursão local (já com root_folder pronto) — retorna caminhos completos de subpastas.""" + paths = [] + try: + for i in range(1, root_folder.Folders.Count + 1): + f = root_folder.Folders.Item(i) + full_path = prefix + f.Name + paths.append(full_path) + # recursão + try: + paths.extend(_list_folders_desktop(f, prefix=full_path + "\\")) + except Exception: + pass + except Exception: + pass + return paths + + +def safe_list_all_folders(): + """ + ✅ Inicializa COM, conecta no Outlook e retorna TODOS os caminhos de pastas + da caixa padrão. Finaliza COM ao terminar. Evita 'CoInitialize não foi chamado'. + """ + try: + import win32com.client + pythoncom.CoInitialize() # inicializa COM + outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI") + root_mailbox = outlook.Folders.Item(1) # índice da caixa de correio padrão + return _list_folders_desktop(root_mailbox, prefix="") + except Exception as e: + st.sidebar.info(f"Não foi possível listar pastas automaticamente ({e}). Informe manualmente abaixo.") + return [] + finally: + try: + pythoncom.CoUninitialize() # finaliza COM + except Exception: + pass + + +def _get_folder_by_path(root_folder, path: str): + parts = [p for p in path.split("\\") if p] + folder = root_folder.Folders.Item(parts[0]) + for p in parts[1:]: + folder = folder.Folders.Item(p) + return folder + + +def _read_folder_items(folder, dias: int, filtro_remetente: str = "") -> pd.DataFrame: + """Lê e-mails de uma pasta específica e retorna DataFrame.""" + items = folder.Items + items.Sort("[ReceivedTime]", True) # decrescente + dt_from = (datetime.now() - timedelta(days=dias)).strftime("%m/%d/%Y %H:%M %p") + try: + items = items.Restrict(f"[ReceivedTime] >= '{dt_from}'") + except Exception: + # Alguns ambientes podem falhar no Restrict; segue sem filtro temporal + pass + + rows = [] + for mail in items: + try: + if getattr(mail, "Class", None) != 43: # 43 = MailItem + continue + try: + sender = mail.SenderEmailAddress or mail.Sender.Name + except Exception: + sender = getattr(mail, "SenderName", None) + + # Filtro opcional por remetente + if filtro_remetente and sender: + if filtro_remetente.lower() not in str(sender).lower(): + continue + + anexos = mail.Attachments.Count if hasattr(mail, "Attachments") else 0 + tamanho_kb = round(mail.Size / 1024, 1) if hasattr(mail, "Size") else None + + rows.append({ + "Pasta": folder.Name, + "Assunto": mail.Subject, + "Remetente": sender, + "RecebidoEm": mail.ReceivedTime.strftime("%Y-%m-%d %H:%M"), + "Anexos": anexos, + "TamanhoKB": tamanho_kb, + "Importancia": str(getattr(mail, "Importance", "")), # 0 baixa, 1 normal, 2 alta + "Categoria": getattr(mail, "Categories", "") or "", + "Lido": bool(getattr(mail, "UnRead", False) == False), + }) + except Exception as e: + rows.append({ + "Pasta": folder.Name, "Assunto": f"[ERRO] {e}", "Remetente": "", + "RecebidoEm": "", "Anexos": "", "TamanhoKB": "", "Importancia": "", "Categoria": "", "Lido": "" + }) + return pd.DataFrame(rows) + + +def gerar_relatorio_outlook_desktop_multi(pastas: list[str], dias: int, filtro_remetente: str = "") -> pd.DataFrame: + """ + ✅ Envolve toda operação COM: inicializa, lê e finaliza. + Evita o erro 'CoInitialize não foi chamado.' + """ + try: + import win32com.client + pythoncom.CoInitialize() # inicializa COM + outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI") + root = outlook.Folders.Item(1) # ajuste o índice se tiver múltiplas caixas + except Exception as e: + st.error(f"Falha ao conectar ao Outlook/pywin32: {e}") + return pd.DataFrame() + + frames = [] + try: + for path in pastas: + try: + folder = _get_folder_by_path(root, path) + df = _read_folder_items(folder, dias, filtro_remetente=filtro_remetente) + df["PastaPath"] = path + frames.append(df) + except Exception as e: + st.warning(f"Não foi possível ler a pasta '{path}': {e}") + return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() + finally: + try: + pythoncom.CoUninitialize() # finaliza COM + except Exception: + pass + + +# ============================== +# UI — Streamlit (seleção de múltiplas pastas) +# ============================== +st.title("📧 Relatório de E-mails • Outlook Desktop (Windows)") +st.caption("Escolha **uma ou várias pastas** da sua Caixa de Entrada, defina o período, filtre por remetente (opcional) e gere o relatório.") + +st.sidebar.header("Configurações") +dias = st.sidebar.slider("Período (últimos N dias)", min_value=1, max_value=365, value=30) +filtro_remetente = st.sidebar.text_input( + "Filtrar por remetente (opcional)", + value="", + placeholder='Ex.: "@fornecedor.com" ou "Fulano"' +) +apenas_inbox = st.sidebar.checkbox("Mostrar somente pastas sob Inbox", value=True) + +# Tentar listar todas as pastas (COM-safe) +todas_pastas = safe_list_all_folders() + +# Filtrar apenas pastas sob Inbox (Caixa de Entrada), se marcado +if todas_pastas: + if apenas_inbox: + opcoes_base = [p for p in todas_pastas if p.lower().startswith("inbox")] + else: + opcoes_base = todas_pastas +else: + opcoes_base = [] + +# Busca por nome +filtro_pasta = st.sidebar.text_input("Pesquisar pasta por nome:", value="") +if filtro_pasta and opcoes_base: + opcoes = [p for p in opcoes_base if filtro_pasta.lower() in p.lower()] +else: + opcoes = opcoes_base or [] + +# Multiselect de pastas +pastas_escolhidas = st.sidebar.multiselect( + "Selecione uma ou mais pastas:", + options=opcoes if opcoes else ["Inbox"], + default=(opcoes[:1] if opcoes else ["Inbox"]), + help="Use '\\' para subpastas. Ex.: Inbox\\Financeiro\\Notas" +) + +# Campo manual adicional (para quem quer escrever um caminho específico não listado) +pasta_manual_extra = st.sidebar.text_input( + "Adicionar caminho manual (opcional)", + value="", + placeholder="Inbox\\Financeiro\\Notas" +) +if pasta_manual_extra.strip(): + pastas_escolhidas = list(set(pastas_escolhidas + [pasta_manual_extra.strip()])) + +# Botão gerar +if st.sidebar.button("🔍 Gerar relatório"): + if not pastas_escolhidas: + st.error("Selecione ao menos uma pasta.") + else: + with st.spinner("Lendo e-mails do Outlook..."): + df = gerar_relatorio_outlook_desktop_multi( + pastas_escolhidas, + dias, + filtro_remetente=filtro_remetente + ) + + st.success(f"Relatório gerado ({len(df)} registros) a partir de {len(pastas_escolhidas)} pasta(s).") + + st.subheader("📄 Resultado") + st.dataframe(df, use_container_width=True) + render_indicators(df, dt_col_name="RecebidoEm") + + base_name = f"relatorio_outlook_desktop_{date.today()}" + build_downloads(df, base_name=base_name) + +st.markdown("---") +st.caption("Dica: se você tem várias caixas postais, troque o índice em `outlook.Folders.Item(1)` para a caixa correta.") diff --git a/audit_streamlit_project.py b/audit_streamlit_project.py new file mode 100644 index 0000000000000000000000000000000000000000..a1c3635ffa881d55fd170b19c0cd33d326965da9 --- /dev/null +++ b/audit_streamlit_project.py @@ -0,0 +1,512 @@ + +# -*- coding: utf-8 -*- +""" +Auditor de projeto Streamlit — chaves duplicadas, estrutura e relacionamentos. + +Verifica: +1) Chaves duplicadas em st.form/st.button/st.download_button. +2) Widgets sem 'key' (risco em loops). +3) Imports faltantes no app.py para módulos usados no roteamento. +4) Cobertura MODULES ↔ Roteamento (entries sem rota e rotas sem entry). +5) Arquivos de módulos inexistentes e módulos sem main(). +6) Imports não usados. +7) Ciclos de importação entre arquivos .py (somente locais). +8) Emite relatório em console e JSON. + +Uso: + python audit_streamlit_project.py + python audit_streamlit_project.py --root . --app app.py --modules modules_map.py --exclude venv .venv .git + +Saída JSON: + .audit_report.json (na raiz especificada) +""" +import os +import re +import ast +import json +import argparse +from collections import defaultdict + +# ----------------------- +# Util — File discovery +# ----------------------- +def find_python_files(root, exclude_dirs=None): + exclude_dirs = set(exclude_dirs or []) + for dirpath, dirnames, filenames in os.walk(root): + # filtra diretorios ignorados + dirnames[:] = [ + d for d in dirnames + if os.path.join(dirpath, d) not in {os.path.join(root, ex) for ex in exclude_dirs} + and d not in exclude_dirs + ] + for fn in filenames: + if fn.endswith(".py"): + yield os.path.join(dirpath, fn) + +def read_text(path): + try: + with open(path, "r", encoding="utf-8") as f: + return f.read() + except Exception: + try: + with open(path, "r", encoding="latin-1") as f: + return f.read() + except Exception: + return "" + +def parse_ast(path): + src = read_text(path) + if not src: + return None, "" + try: + tree = ast.parse(src, filename=path) + return tree, src + except Exception: + return None, src + +# ----------------------- +# Scan — Streamlit keys +# ----------------------- +KEY_PATTERNS = { + "form_literal": re.compile(r'st\.form\(\s*\'"[\'"]'), + "button_key": re.compile(r'st\.button\([^)]*key\s*=\s*\'"[\'"]'), + "download_key": re.compile(r'st\.download_button\([^)]*key\s*=\s*\'"[\'"]'), +} +# widgets sem key (para alertar) +MISSING_KEY_PATTERNS = { + "button_no_key": re.compile(r'st\.button\((?![^)]*key\s*=)'), + "download_no_key": re.compile(r'st\.download_button\((?![^)]*key\s*=)'), +} + +def scan_duplicate_and_missing_keys(file_path): + dups = defaultdict(list) + missing = defaultdict(list) + try: + with open(file_path, "r", encoding="utf-8") as f: + for i, line in enumerate(f, 1): + # dup keys + for _, pat in KEY_PATTERNS.items(): + for m in pat.finditer(line): + dups[m.group(1)].append(i) + # missing key warnings + for name, pat in MISSING_KEY_PATTERNS.items(): + if pat.search(line): + missing[name].append(i) + except Exception: + pass + dup_filtered = {k: v for k, v in dups.items() if len(v) > 1} + return dup_filtered, missing + +# ----------------------- +# AST helpers — imports +# ----------------------- +def extract_imports_defs_calls(tree): + """ + Retorna: + imports: { alias_ou_nome -> modulo_base } + used_names: set de nomes referenciados + defs: set de nomes de funções definidas + calls_main: set de nomes/lvalues em chamadas *.main() + """ + imports = {} # alias -> base_module + used_names = set() + defs = set() + calls_main = set() + + class V(ast.NodeVisitor): + def visit_Import(self, node): + for alias in node.names: + base = alias.name.split(".")[0] + asname = alias.asname or alias.name + asname = asname.split(".")[0] + imports[asname] = base + + def visit_ImportFrom(self, node): + if node.module: + base = node.module.split(".")[0] + for alias in node.names: + asname = alias.asname or alias.name + imports[asname] = base + + def visit_FunctionDef(self, node): + defs.add(node.name) + self.generic_visit(node) + + def visit_Name(self, node): + used_names.add(node.id) + + def visit_Attribute(self, node): + # captura padrão X.main(...) + if isinstance(node.ctx, ast.Load) and getattr(node, "attr", None) == "main": + if isinstance(node.value, ast.Name): + calls_main.add(node.value.id) + else: + # pkg.sub.main -> tenta achar o nome raiz + root = node.value + while isinstance(root, ast.Attribute): + root = root.value + if isinstance(root, ast.Name): + calls_main.add(root.id) + self.generic_visit(node) + + if tree: + V().visit(tree) + return imports, used_names, defs, calls_main + +# ----------------------- +# modules_map.py — parse +# ----------------------- +def load_modules_map(modules_map_path): + """ + Extrai: + - route_keys: chaves top-level do dict MODULES (ex.: "consulta", "operacao"...) + - internal_keys: valores do campo "key" dentro de cada entrada + """ + route_keys = set() + internal_keys = set() + src = read_text(modules_map_path) + if not src: + return route_keys, internal_keys + # chaves top-level (aproximação): linhas com " \"nome\": {" + for m in re.finditer(r'^[ \t]*"([^"]+)"\s*:\s*\{', src, re.MULTILINE): + route_keys.add(m.group(1)) + # field "key": "valor" + for m in re.finditer(r'"key"\s*:\s*"([^"]+)"', src): + internal_keys.add(m.group(1)) + return route_keys, internal_keys + +# ----------------------- +# Roteamento em app.py +# ----------------------- +def extract_routing(app_src): + """ + Busca padrões: + if/elif pagina_id == "consulta": + consulta.main() + Retorna lista de tuplas: (route_key, called_module_name) + """ + routes = [] + + # bloco "if" inicial + m_if = re.search( + r'if\s+pagina_id\s*==\s*\'"[\'"]\s*:\s*(.*?)\n\s*(?:elif|#|$)', + app_src, re.DOTALL + ) + if m_if: + route = m_if.group(1) + block = m_if.group(2) + called = None + cm = re.search(r'([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*main\s*\(', block) + if cm: + called = cm.group(1) + routes.append((route, called)) + + # blocos "elif" + for m in re.finditer( + r'elif\s+pagina_id\s*==\s*\'"[\'"]\s*:\s*(.*?)\n\s*(?:elif|#|$)', + app_src, re.DOTALL + ): + route = m.group(1) + block = m.group(2) + called = None + cm = re.search(r'([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*main\s*\(', block) + if cm: + called = cm.group(1) + routes.append((route, called)) + + return routes + +# ----------------------- +# Import graph & cycles +# ----------------------- +def build_local_import_graph(py_files): + """ + Monta grafo de importações locais: base_name -> { base_names importados } + """ + # mapeia base_name -> arquivo + base_to_file = {} + for f in py_files: + base = os.path.splitext(os.path.basename(f))[0] + base_to_file[base] = f + + graph = defaultdict(set) + for f in py_files: + base = os.path.splitext(os.path.basename(f))[0] + tree, _ = parse_ast(f) + imports, _, _, _ = extract_imports_defs_calls(tree) + for alias, base_mod in imports.items(): + # se alias ou base_mod mapeia para arquivo local, considera aresta + target = None + if alias in base_to_file: + target = alias + elif base_mod in base_to_file: + target = base_mod + if target and target != base: + graph[base].add(target) + return graph + +def find_cycles(graph): + """ + Detecta ciclos no grafo (lista de ciclos) — sem mutar o dicionário durante a iteração. + """ + # Conjunto estático de nós (origens + destinos) + nodes = set(graph.keys()) + for vs in graph.values(): + nodes.update(vs) + + visited = set() + stack = set() + cycles = [] + path = [] + + def dfs(u): + visited.add(u) + stack.add(u) + path.append(u) + for v in graph.get(u, set()): # <- sem criar chaves novas + if v not in visited: + dfs(v) + elif v in stack: + # ciclo encontrado — extrai subpath (v até fim) + fecha em v + if v in path: + idx = len(path) - 1 + while idx >= 0 and path[idx] != v: + idx -= 1 + if idx >= 0: + cycle = path[idx:] + [v] + cycles.append(cycle) + stack.remove(u) + path.pop() + + for node in list(nodes): # <- lista estática + if node not in visited: + dfs(node) + + # Deduplicar ciclos por forma canônica (rotação mínima) + def canonical(cyc): + core = cyc[:-1] # remove a repetição final + if not core: + return tuple() + rots = [tuple(core[i:] + core[:i]) for i in range(len(core))] + return min(rots) + + seen = set() + unique = [] + for cyc in cycles: + can = canonical(cyc) + if can and can not in seen: + seen.add(can) + unique.append(cyc) + return unique + +# ----------------------- +# Unused imports (aprox) +# ----------------------- +def find_unused_imports(tree, imports, used_names): + """ + Aproximação: se o alias importado não aparece em used_names -> não usado. + Não detecta usos por getattr/reflection; serve como guia inicial. + """ + unused = [] + for alias in imports.keys(): + if alias not in used_names: + unused.append(alias) + return unused + +# ----------------------- +# Auditor principal +# ----------------------- +def audit(root, app_path, modules_map_path, exclude_dirs=None, output_json=".audit_report.json"): + report = { + "duplicate_keys": {}, # file -> {key: [lines]} + "widgets_without_key": {}, # file -> {pattern: [lines]} + "missing_imports_in_app": [], # [(route_key, called_module, reason)] + "routing_vs_modules": { + "routes_without_modules_entry": [], # [route_key] + "modules_entry_without_route": [], # [modules_map_key] + }, + "module_files_missing": [], # [module_name] + "modules_without_main": [], # [module_name] + "unused_imports": {}, # file -> [alias] + "import_cycles": [], # [[mod_a, mod_b, ..., mod_a]] + } + + # 1) varrer arquivos + py_files = list(find_python_files(root, exclude_dirs=exclude_dirs)) + # mapa base_name -> file + base_to_file = {os.path.splitext(os.path.basename(f))[0]: f for f in py_files} + + # 2) chaves duplicadas e widgets sem key + for f in py_files: + dups, missing = scan_duplicate_and_missing_keys(f) + if dups: + report["duplicate_keys"][f] = dups + if any(missing.values()): + report["widgets_without_key"][f] = {k: v for k, v in missing.items() if v} + + # 3) carrega app.py e modules_map.py + app_full = os.path.join(root, app_path) + modules_map_full = os.path.join(root, modules_map_path) + app_tree, app_src = parse_ast(app_full) + routes = extract_routing(app_src) if app_src else [] + + # imports e defs do app + app_imports, app_used, app_defs, app_calls_main = extract_imports_defs_calls(app_tree) + + # 4) MODULES + route_keys_in_map, internal_keys_in_map = load_modules_map(modules_map_full) + + # 5) checar import para cada rota + routes_set = set() + for route_key, called_module in routes: + routes_set.add(route_key) + if not called_module: + report["missing_imports_in_app"].append((route_key, None, "Bloco da rota não chama *.main()")) + continue + # foi importado? + imported_aliases = set(app_imports.keys()) # aliases disponíveis + if called_module not in imported_aliases: + report["missing_imports_in_app"].append((route_key, called_module, "Módulo não importado no app.py")) + # arquivo existe? + if called_module not in base_to_file: + # talvez seja alias de import (base module) + base_mod = app_imports.get(called_module) + if not (base_mod and base_mod in base_to_file): + report["module_files_missing"].append(called_module) + else: + # checar main() + t, _ = parse_ast(base_to_file[called_module]) + _, _, defs, _ = extract_imports_defs_calls(t) + if "main" not in defs: + report["modules_without_main"].append(called_module) + + # 6) cobertura rota vs modules_map + # - rotas no app que não existem no modules_map + for r in routes_set: + if r not in route_keys_in_map and r not in internal_keys_in_map: + report["routing_vs_modules"]["routes_without_modules_entry"].append(r) + # - entries no modules_map que não têm rota no app + for m in route_keys_in_map: + if m not in routes_set: + report["routing_vs_modules"]["modules_entry_without_route"].append(m) + + # 7) unused imports por arquivo + for f in py_files: + t, _ = parse_ast(f) + imp, used, defs, calls_main = extract_imports_defs_calls(t) + unused = find_unused_imports(t, imp, used) + if unused: + report["unused_imports"][f] = unused + + # 8) ciclos de import local + graph = build_local_import_graph(py_files) + cycles = find_cycles(graph) + report["import_cycles"] = cycles + + # 9) remover duplicidades simples nas listas + report["missing_imports_in_app"] = list(dict.fromkeys(report["missing_imports_in_app"])) + report["module_files_missing"] = sorted(set(report["module_files_missing"])) + report["modules_without_main"] = sorted(set(report["modules_without_main"])) + report["routing_vs_modules"]["routes_without_modules_entry"] = sorted( + set(report["routing_vs_modules"]["routes_without_modules_entry"])) + report["routing_vs_modules"]["modules_entry_without_route"] = sorted( + set(report["routing_vs_modules"]["modules_entry_without_route"])) + + # 10) saída + print("\n=== RELATÓRIO DE AUDITORIA — Streamlit Project ===") + # chaves duplicadas + print("\n[Chaves duplicadas]") + if not report["duplicate_keys"]: + print(" ✔ Nenhuma chave duplicada literal encontrada.") + else: + for file, dups in report["duplicate_keys"].items(): + print(f" - {file}") + for key, lines in dups.items(): + print(f" * key='{key}' duplicada em linhas {lines}") + + # widgets sem key + print("\n[Widgets sem 'key' (atenção em loops)]") + if not report["widgets_without_key"]: + print(" ✔ Nenhum potencial widget sem key encontrado.") + else: + for file, miss in report["widgets_without_key"].items(): + print(f" - {file}") + for kind, lines in miss.items(): + print(f" * {kind}: linhas {lines}") + + # imports faltantes e módulos + print("\n[Imports faltantes no app e módulos]") + if not report["missing_imports_in_app"]: + print(" ✔ Nenhum import faltante detectado no app.py (para rotas).") + else: + for route_key, called_module, reason in report["missing_imports_in_app"]: + print(f" - rota='{route_key}' -> módulo='{called_module}' • {reason}") + if not report["module_files_missing"]: + print(" ✔ Nenhum arquivo de módulo ausente detectado.") + else: + print(" Arquivos de módulo não encontrados:", report["module_files_missing"]) + if not report["modules_without_main"]: + print(" ✔ Todos os módulos localizados possuem main().") + else: + print(" Módulos sem main():", report["modules_without_main"]) + + # cobertura MODULES ↔ Roteamento + print("\n[Consistência: MODULES x Roteamento]") + rwm = report["routing_vs_modules"] + if not rwm["routes_without_modules_entry"]: + print(" ✔ Todas as rotas possuem entrada em modules_map.py (ou 'key' interna).") + else: + print(" Rotas sem entrada no modules_map.py:", rwm["routes_without_modules_entry"]) + if not rwm["modules_entry_without_route"]: + print(" ✔ Todas as entradas do modules_map.py possuem rota no app.py.") + else: + print(" Entradas do modules_map.py sem rota no app.py:", rwm["modules_entry_without_route"]) + + # imports não usados + print("\n[Imports não usados (aprox.)]") + if not report["unused_imports"]: + print(" ✔ Nenhum import potencialmente não usado encontrado.") + else: + for file, unused in report["unused_imports"].items(): + print(f" - {file}: {unused}") + + # ciclos + print("\n[Ciclos de importação]") + if not report["import_cycles"]: + print(" ✔ Nenhum ciclo de importação detectado.") + else: + for cyc in report["import_cycles"]: + print(" - ciclo:", " -> ".join(cyc)) + + # salvar JSON + out_path = os.path.join(root, output_json) + with open(out_path, "w", encoding="utf-8") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + print(f"\n📄 Relatório JSON salvo em: {out_path}") + + return report + +# ----------------------- +# CLI +# ----------------------- +def cli(): + p = argparse.ArgumentParser(description="Auditor de projeto Streamlit") + p.add_argument("--root", default=".", help="Raiz do projeto (default: .)") + p.add_argument("--app", default="app.py", help="Caminho do app.py (relativo à raiz)") + p.add_argument("--modules", default="modules_map.py", help="Caminho do modules_map.py (relativo à raiz)") + p.add_argument("--exclude", nargs="*", default=[".git", ".venv", "venv", "__pycache__", ".streamlit"], + help="Pastas a excluir da varredura") + p.add_argument("--json", default=".audit_report.json", help="Nome do arquivo JSON de saída") + args = p.parse_args() + + audit( + root=args.root, + app_path=args.app, + modules_map_path=args.modules, + exclude_dirs=args.exclude, + output_json=args.json + ) + +if __name__ == "__main__": + cli() + diff --git a/auditoria.py b/auditoria.py new file mode 100644 index 0000000000000000000000000000000000000000..bc6ef70ea25dbd8acfd01d968736998db875e398 --- /dev/null +++ b/auditoria.py @@ -0,0 +1,100 @@ + +import streamlit as st +from banco import SessionLocal +from models import LogAcesso, Usuario +import pandas as pd +from io import BytesIO +import os + +# Debug opcional – confirma o banco em uso +print("📂 BANCO LIDO NA AUDITORIA:", os.path.abspath("load.db")) + + +def main(): + st.title("🧾 Auditoria do Sistema Load") + + db = SessionLocal() + + try: + # ========================= + # FILTRO POR PERFIL + # ========================= + perfis = ( + db.query(Usuario.perfil) + .distinct() + .order_by(Usuario.perfil) + .all() + ) + + lista_perfis = ["Todos"] + [p[0] for p in perfis] + + perfil_selecionado = st.selectbox( + "Filtrar por perfil:", + lista_perfis + ) + + # ========================= + # CONSULTA COM JOIN + # ========================= + # ✅ Incluímos o e-mail do usuário na seleção + query = ( + db.query( + LogAcesso.usuario, + Usuario.perfil, + Usuario.email, # <-- novo + LogAcesso.acao, + LogAcesso.tabela, + LogAcesso.registro_id, + LogAcesso.data_hora + ) + .join(Usuario, Usuario.usuario == LogAcesso.usuario) + .order_by(LogAcesso.data_hora.desc()) + ) + + if perfil_selecionado != "Todos": + query = query.filter(Usuario.perfil == perfil_selecionado) + + logs = query.all() + + if not logs: + st.info("Nenhum registro encontrado.") + return + + # ========================= + # DATAFRAME FORMATADO + # ========================= + dados = [] + for l in logs: + # l = (usuario, perfil, email, acao, tabela, registro_id, data_hora) + dados.append({ + "Usuário": l[0], + "Perfil": l[1], + "E-mail": l[2] or "—", # ✅ e-mail pode ser nulo + "Ação": l[3], + "Tabela": l[4], + "Registro": l[5], + "Data": l[6].strftime("%d/%m/%Y"), + "Hora": l[6].strftime("%H:%M:%S"), + }) + + df = pd.DataFrame(dados) + + st.dataframe(df, use_container_width=True) + + # ========================= + # EXPORTAÇÃO PARA EXCEL + # ========================= + buffer = BytesIO() + with pd.ExcelWriter(buffer, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Auditoria") + + st.download_button( + label="📥 Exportar Auditoria para Excel", + data=buffer.getvalue(), + file_name="auditoria_sistema.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + + finally: + db.close() + diff --git a/auditoria_cleanup.py b/auditoria_cleanup.py new file mode 100644 index 0000000000000000000000000000000000000000..0706b12b6663b2192cd8c2c9c621294ad89b990d --- /dev/null +++ b/auditoria_cleanup.py @@ -0,0 +1,103 @@ + +import streamlit as st +from datetime import datetime, timedelta +from banco import SessionLocal +from models import LogAcesso # ✅ usar a tabela correta +from utils_auditoria import registrar_log + + +def main(): + st.title("🧹 Limpeza de Logs de Auditoria") + + st.markdown( + """ + Este módulo permite excluir registros antigos de auditoria + para suavizar o banco de dados. + """ + ) + + opcoes = { + "Último mês": 30, + "Últimos 2 meses": 60, + "Últimos 6 meses": 180, + "Últimos 12 meses": 365, + "Personalizado": None, + } + + escolha = st.selectbox("📅 Escolha o período:", list(opcoes.keys())) + + data_inicio = None + data_fim = datetime.now() + + if escolha != "Personalizado": + dias = opcoes[escolha] + data_inicio = datetime.now() - timedelta(days=dias) + else: + col1, col2 = st.columns(2) + with col1: + data_inicio = st.date_input("Data inicial") + with col2: + data_fim = st.date_input("Data final") + data_inicio = datetime.combine(data_inicio, datetime.min.time()) + data_fim = datetime.combine(data_fim, datetime.max.time()) + + st.info(f"🗓️ Registros de auditoria entre {data_inicio.date()} e {data_fim.date()} serão excluídos.") + + st.divider() + + # ✅ Prévia do total (opcional, ajuda na decisão) + with SessionLocal() as db: + total_prev = ( + db.query(LogAcesso) + .filter(LogAcesso.data_hora >= data_inicio, LogAcesso.data_hora <= data_fim) + .count() + ) + st.info(f"🔎 Prévia: {total_prev} registro(s) serão removidos no período selecionado.") + + # ✅ Etapa de confirmação via caixa de seleção + st.warning( + "⚠️ **Atenção:** Todos os registros de auditoria no período selecionado serão apagados.\n\n" + "Confirme abaixo para prosseguir." + ) + confirmacao = st.selectbox("Confirmar exclusão?", ["Não", "SIM"], index=0) + + # Botão de exclusão (só prossegue se confirmação for SIM) + if st.button("❌ Excluir registros de auditoria"): + if confirmacao != "SIM": + st.error("Operação cancelada. Se desejar prosseguir, selecione **SIM** na confirmação.") + return + + with SessionLocal() as db: + try: + registros = ( + db.query(LogAcesso) + .filter(LogAcesso.data_hora >= data_inicio, LogAcesso.data_hora <= data_fim) + .all() + ) + + total = len(registros) + + if total == 0: + st.warning("Nenhum registro encontrado para exclusão.") + return + + for r in registros: + db.delete(r) + + db.commit() + + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"Excluiu {total} registros de auditoria entre {data_inicio.date()} e {data_fim.date()}", + tabela="log_acesso", + registro_id=None + ) + + st.success(f"🎉 {total} registro(s) de auditoria foram excluídos com sucesso!") + + except Exception as e: + db.rollback() + st.error(f"❌ Erro ao excluir registros: {e}") + + + diff --git a/auto_capture.py b/auto_capture.py new file mode 100644 index 0000000000000000000000000000000000000000..64ae09a2035769543439f5de38bdd08bffc2f6c9 --- /dev/null +++ b/auto_capture.py @@ -0,0 +1,319 @@ + +# -*- coding: utf-8 -*- +""" +auto_capture.py — Captura screenshots de todas as telas do app Streamlit e monta um PPTX. + +Recursos: +• Login automático (usuário/senha + escolha do banco) +• Bypass do Quiz (clica: “Voltar ao sistema”, “Finalizar”, “Continuar”, se visível) +• Seletores robustos para st.selectbox (procura pelo label visível) +• Captura pós-login/pós-quiz, por grupo e por módulo +• Artefatos de debug (HTML + PNG) quando algo falha +• Sanitização de nomes de arquivo (compatível com Windows) +• Geração de PPTX com um slide por módulo capturado + +Requisitos: +pip install playwright python-pptx python-dotenv +playwright install +""" + +import os +import re +import traceback +from datetime import datetime +from dotenv import load_dotenv + +# Carrega .env +load_dotenv() + +APP_URL = os.getenv("APP_URL", "http://localhost:8501") +LOGIN_USER = os.getenv("LOGIN_USER", "admin") +LOGIN_PASS = os.getenv("LOGIN_PASS", "admin123") +BANK_CHOICE = os.getenv("BANK_CHOICE", "prod") # prod | test | treinamento + +SCREEN_DIR = os.getenv("SCREEN_DIR", "./screenshots") +OUTPUT_PPTX = os.getenv("OUTPUT_PPTX", "./demo_funcionalidades.pptx") + +HEADLESS = os.getenv("AUTOCAPTURE_HEADLESS", "false").lower() == "true" +VIEWPORT_W = int(os.getenv("AUTOCAPTURE_VIEWPORT_W", "1440")) +VIEWPORT_H = int(os.getenv("AUTOCAPTURE_VIEWPORT_H", "900")) + +# Importa seu mapa de módulos (aproveita rótulos e grupos) +try: + from modules_map import MODULES +except Exception: + MODULES = {} + print("⚠️ Não consegui importar modules_map.py. Ele deve estar no mesmo diretório do script.") + +# PowerPoint +from pptx import Presentation +from pptx.util import Inches, Pt +from pptx.dml.color import RGBColor + +# Playwright +from playwright.sync_api import sync_playwright + + +# ----------------------------------------------------------------------------- +# Helpers +# ----------------------------------------------------------------------------- +def ensure_dir(path: str): + os.makedirs(path, exist_ok=True) + +def sanitize(s: str) -> str: + """Remove/normaliza caracteres inválidos de nomes (Windows-safe).""" + s = re.sub(r"[\\/:*?\"<>|]", "_", s) # remove proibidos + s = re.sub(r"\s+", "_", s.strip()) # espaços -> _ + return s + +def bank_label(choice: str) -> str: + return { + "prod": "Banco 1 (📗 Produção)", + "test": "Banco 2 (📕 Teste)", + "treinamento": "Banco 3 (📘 Treinamento)", + }.get(choice, choice) + +def save_artifacts_on_fail(page, tag="fail"): + """Salva HTML e screenshot quando algo dá errado.""" + ensure_dir(SCREEN_DIR) + tag = sanitize(tag) + try: + html_path = os.path.join(SCREEN_DIR, f"{tag}_page.html") + img_path = os.path.join(SCREEN_DIR, f"{tag}_page.png") + with open(html_path, "w", encoding="utf-8") as f: + f.write(page.content()) + page.screenshot(path=img_path, full_page=True) + print(f"📝 Artefatos salvos: {html_path}, {img_path}") + except Exception as e: + print(f"⚠️ Falha ao salvar artefatos de erro: {e}") + +def select_by_label(page, select_label: str, option_text: str): + """ + Seleciona uma opção em um st.selectbox, procurando pelo label (texto visível). + • Varre todos os elementos com data-testid="stSelectbox" + • Encontra o que contém o label desejado (case-insensitive) + • Abre o combobox e clica na opção exata + """ + boxes = page.locator('[data-testid="stSelectbox"]') + count = boxes.count() + if count == 0: + raise RuntimeError("Nenhum stSelectbox encontrado na página.") + + found = False + for i in range(count): + box = boxes.nth(i) + try: + txt = box.inner_text().strip() + except Exception: + continue + if select_label.lower() in txt.lower(): + box.locator('div[role="combobox"]').first.click() + page.locator('div[role="listbox"]').get_by_text(option_text, exact=True).click() + found = True + break + + if not found: + raise RuntimeError(f"Selectbox com label '{select_label}' não encontrado.") + +def bypass_quiz(page): + """ + Tenta sair da tela de Quiz, caso esteja bloqueando a navegação. + Procura ações típicas: 'Voltar ao sistema', 'Finalizar', 'Continuar'. + """ + # 1) Voltar ao sistema + try: + if page.get_by_text("Voltar ao sistema").count() > 0: + page.get_by_text("Voltar ao sistema").click() + page.wait_for_timeout(600) + return + except Exception: + pass + + # 2) Finalizar + try: + if page.get_by_role("button", name="Finalizar").count() > 0: + page.get_by_role("button", name="Finalizar").click() + page.wait_for_timeout(600) + return + except Exception: + pass + + # 3) Continuar + try: + if page.get_by_role("button", name="Continuar").count() > 0: + page.get_by_role("button", name="Continuar").click() + page.wait_for_timeout(600) + return + except Exception: + pass + + # 4) Se nada funcionar, salva artefatos para analisarmos o DOM real + save_artifacts_on_fail(page, "quiz_bypass") + + +def do_login(page): + page.goto(APP_URL, timeout=60000) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(800) + + # Seleciona Banco (selectbox "Usar banco:") + try: + select_by_label(page, "Usar banco:", bank_label(BANK_CHOICE)) + except Exception as e: + print(f"⚠️ Falha ao selecionar banco: {e}") + save_artifacts_on_fail(page, "select_bank") + # Fallback por texto simples (última tentativa) + try: + page.get_by_text("Usar banco:").click() + page.get_by_text(bank_label(BANK_CHOICE), exact=True).click() + except Exception: + pass + + # Preenche credenciais + try: + page.get_by_label("Usuário").fill(LOGIN_USER) + except Exception: + page.locator('label:has-text("Usuário")').locator("xpath=..").locator('input').fill(LOGIN_USER) + + try: + page.get_by_label("Senha").fill(LOGIN_PASS) + except Exception: + page.locator('label:has-text("Senha")').locator("xpath=..").locator('input').fill(LOGIN_PASS) + + # Entrar + try: + page.get_by_role("button", name="Entrar").click() + except Exception: + page.get_by_text("Entrar").click() + + page.wait_for_load_state("networkidle") + page.wait_for_timeout(1000) + + # Captura pós-login + page.screenshot(path=os.path.join(SCREEN_DIR, "00_pos_login.png"), full_page=True) + + # Bypass do Quiz (se existir) + bypass_quiz(page) + + # Captura pós-quiz + page.screenshot(path=os.path.join(SCREEN_DIR, "01_pos_quiz.png"), full_page=True) + + +def clear_search(page): + """Limpa campo 'Pesquisar módulo:' para não filtrar nada (opcional).""" + try: + page.get_by_label("Pesquisar módulo:").fill("") + page.wait_for_timeout(200) + except Exception: + # Fallback: tenta input na sidebar + try: + sb = page.locator('[data-testid="stSidebar"]').first + sb.locator('input').first.fill("") + except Exception: + pass + + +def capture_all_screens(): + ensure_dir(SCREEN_DIR) + screenshots = [] + + from playwright.sync_api import TimeoutError + + with sync_playwright() as pw: + browser = pw.chromium.launch(headless=HEADLESS) + context = browser.new_context(viewport={"width": VIEWPORT_W, "height": VIEWPORT_H}) + page = context.new_page() + + # Login + do_login(page) + + # Grupos + grupos = sorted({MODULES[mid].get("grupo", "Outros") for mid in MODULES}) if MODULES else [] + if not grupos: + print("⚠️ MODULES está vazio. Não há módulos para capturar.") + save_artifacts_on_fail(page, "no_modules") + context.close(); browser.close() + return screenshots + + for grupo in grupos: + try: + clear_search(page) + select_by_label(page, "Selecione a operação:", grupo) + page.wait_for_timeout(500) + + gshot = os.path.join(SCREEN_DIR, f"{BANK_CHOICE}_grupo_{sanitize(grupo)}.png") + page.screenshot(path=gshot, full_page=True) + print(f"📸 Grupo: {grupo} → {gshot}") + except Exception as e: + print(f"⚠️ Falha ao selecionar grupo '{grupo}': {e}") + save_artifacts_on_fail(page, f"grupo_{grupo}") + continue + + # Módulos do grupo + mod_ids = [mid for mid in MODULES if MODULES[mid].get("grupo", "Outros") == grupo] + for mid in mod_ids: + label = MODULES[mid].get("label", mid) + try: + select_by_label(page, "Selecione o módulo:", label) + page.wait_for_load_state("networkidle") + page.wait_for_timeout(800) + + fname = f"{BANK_CHOICE}_{sanitize(grupo)}_{sanitize(mid)}.png" + fpath = os.path.join(SCREEN_DIR, fname) + page.screenshot(path=fpath, full_page=True) + screenshots.append((mid, label, grupo, fpath)) + print(f"📸 Módulo: {label} → {fpath}") + except Exception as e: + print(f"❌ Falha ao capturar módulo '{label}': {e}") + save_artifacts_on_fail(page, f"mod_{mid}") + traceback.print_exc() + continue + + context.close() + browser.close() + + return screenshots + + +def build_pptx(screens, out_path): + prs = Presentation() + + # Slide de título + slide = prs.slides.add_slide(prs.slide_layouts[0]) + slide.shapes.title.text = "Apresentação do Sistema (ARM LoadApp)" + subtitle = slide.placeholders[1].text_frame + subtitle.clear() + p = subtitle.paragraphs[0] + p.text = f"Ambiente: {bank_label(BANK_CHOICE)} | Gerado em {datetime.now().strftime('%d/%m/%Y %H:%M:%S')}" + p.font.size = Pt(14) + + # Slides por módulo + for mid, label, grupo, fpath in screens: + layout = prs.slides.add_slide(prs.slide_layouts[5]) # Title Only + layout.shapes.title.text = f"{label} • {grupo}" + left, top, width = Inches(0.5), Inches(1.2), Inches(9) + try: + layout.shapes.add_picture(fpath, left, top, width=width) + except Exception: + tx = layout.shapes.add_textbox(left, top, Inches(9), Inches(1)) + tf = tx.text_frame + tf.text = f"(Falha ao inserir imagem: {os.path.basename(fpath)})" + tf.paragraphs[0].font.color.rgb = RGBColor(200, 0, 0) + + prs.save(out_path) + print(f"🎉 PPTX gerado: {out_path}") + + +def main(): + print(f"🚀 Captura em {APP_URL} | Banco: {BANK_CHOICE} ({bank_label(BANK_CHOICE)}) | headless={HEADLESS}") + ensure_dir(SCREEN_DIR) + screens = capture_all_screens() + if not screens: + print("⚠️ Nenhuma captura gerada. Veja os artefatos na pasta e revise seletores/menus.") + return + build_pptx(screens, OUTPUT_PPTX) + + +if __name__ == "__main__": + main() + diff --git a/banco.py b/banco.py new file mode 100644 index 0000000000000000000000000000000000000000..7f56bdfe71f76f9d347c3fe27939490efe5261e1 --- /dev/null +++ b/banco.py @@ -0,0 +1,121 @@ +# -*- coding: utf-8 -*- +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +import os +from dotenv import load_dotenv +import importlib + +# 🔒 Caminho absoluto do projeto +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Carrega variáveis de ambiente (.env) antes de ler DATABASE_URL +load_dotenv() + +# ============================================================ +# 🔀 SUPORTE A DOIS BANCOS (Produção/Teste) COM FALLBACK +# ============================================================ +# Tentamos usar o roteador (db_router.py). Se não existir ainda, +# caímos no comportamento original usando apenas DATABASE_URL. +try: + from db_router import ( + get_engine as _router_get_engine, + get_session_factory as _router_get_session_factory, + SessionLocal as _router_SessionLocal, + ) + _HAS_ROUTER = True +except Exception: + _HAS_ROUTER = False + +# 🔧 Fallback: mesma lógica do seu módulo original — um único DATABASE_URL +DATABASE_URL = os.getenv( + "DATABASE_URL", + f"sqlite:///{os.path.join(BASE_DIR, 'load.db')}" +) + +engine_args = { + "echo": False, + "pool_pre_ping": True, +} + +# Parâmetros específicos para SQLite (apenas se o fallback estiver ativo) +if DATABASE_URL.startswith("sqlite"): + engine_args["connect_args"] = {"check_same_thread": False} + +# ============================================================ +# Engine / SessionLocal (com ou sem roteador) +# ============================================================ +if _HAS_ROUTER: + # ✅ Usa engine e SessionLocal do banco ATIVO (Produção/Teste), conforme escolha no login + def get_engine(): + return _router_get_engine() + + def _session_factory(): + return _router_get_session_factory() + + # A SessionLocal do roteador já entrega sessões no banco ativo + SessionLocal = _router_SessionLocal + +else: + # ✅ Fallback: comportamento original com DATABASE_URL único + _engine = create_engine(DATABASE_URL, **engine_args) + + def get_engine(): + return _engine + + _SessionFactory = sessionmaker( + autocommit=False, + autoflush=False, + bind=_engine, + ) + + def _session_factory(): + return _SessionFactory + + # Compatível com seu uso atual: SessionLocal() -> sessão + SessionLocal = _SessionFactory + +# ⚠️ Compatibilidade: expõe 'engine' resolvendo via get_engine() +# Observação importante: +# - Se trocar o banco após a importação deste módulo (via login), +# prefira sempre chamar get_engine() ou criar sessões com SessionLocal(), +# pois 'engine' abaixo é resolvido apenas uma vez (na importação). +engine = get_engine() + +# ORM Base +Base = declarative_base() + +# ============================================================ +# 🛠️ Utilitários (opcionais) +# ============================================================ +def init_schema(): + """ + Cria/atualiza as tabelas no banco ATIVO. + • Com roteador: aplica no banco escolhido (Produção/Teste). + • Sem roteador: aplica no DATABASE_URL padrão. + Use em DEV/TESTE; em produção, prefira migrações (ex.: Alembic). + """ + # Importa 'models' de forma tardia e segura (sem wildcard) para registrar todos os mapeamentos + # antes de criar as tabelas. Isso evita import circular no topo. + try: + importlib.import_module("models") + except ModuleNotFoundError: + # Se seus modelos estiverem em outro pacote/caminho, ajuste aqui: + # importlib.import_module("app.models") # exemplo + raise + + Base.metadata.create_all(bind=get_engine()) + +def db_info() -> dict: + """ + Retorna informações básicas do banco ativo (para debug/UX). + """ + eng = get_engine() + try: + url = str(eng.url) + except Exception: + url = DATABASE_URL + return { + "url": url, + "using_router": _HAS_ROUTER, + } + diff --git a/bi.py b/bi.py new file mode 100644 index 0000000000000000000000000000000000000000..c51160a8a9c82307e3ab1d6a88dbbdee21eb2d0c --- /dev/null +++ b/bi.py @@ -0,0 +1,74 @@ +import streamlit as st +import streamlit.components.v1 as components +from banco import SessionLocal +from models import LogAcesso +from datetime import datetime + + +# ===================================================== +# AUDITORIA +# ===================================================== +def registrar_auditoria(acao): + db = SessionLocal() + try: + db.add(LogAcesso( + usuario=st.session_state.get("usuario", "desconhecido"), + acao=acao, + tabela="bi", + data_hora=datetime.now() + )) + db.commit() + finally: + db.close() + + +# ===================================================== +# APP PRINCIPAL +# ===================================================== +def main(): + st.title("📊 Business Intelligence") + + st.caption("Indicadores e dashboards oficiais") + + # 🔐 Auditoria de acesso + registrar_auditoria("ACESSO_BI") + + # ===================================================== + # SELEÇÃO DE DASHBOARD + # ===================================================== + dashboards = { + "📈 Performance Operacional": { + "url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI", + "height": 800 + }, + "📊 Qualidade e Erros": { + "url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI", + "height": 800 + }, + "📦 Produtividade FPSO": { + "url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI", + "height": 900 + } + } + + opcao = st.selectbox("Selecione o Dashboard", dashboards.keys()) + + dash = dashboards[opcao] + + st.divider() + + # ===================================================== + # EMBED DO POWER BI + # ===================================================== + components.html( + f""" + + """, + height=dash["height"] + 20 + ) diff --git a/cadastro_py.py b/cadastro_py.py new file mode 100644 index 0000000000000000000000000000000000000000..7108f87537de6c90ec0d68993028cb9874c143b3 --- /dev/null +++ b/cadastro_py.py @@ -0,0 +1,28 @@ +from banco import SessionLocal +from models import FPSO + +FPSO_PADRAO = [ + "CDA", "CDP", "CDM", "ADG", + "ESS", "SEP", "CDI", "ATD", "CDS" +] + +def main(): + db = SessionLocal() + try: + existentes = {f.nome for f in db.query(FPSO).all()} + + for nome in FPSO_PADRAO: + if nome not in existentes: + db.add(FPSO(nome=nome)) + + db.commit() + print("✅ FPSOs padrão inseridos com sucesso!") + + finally: + db.close() + + +if __name__ == "__main__": + main() + + diff --git a/calendario.py b/calendario.py new file mode 100644 index 0000000000000000000000000000000000000000..c198ba0ad6f64e004ccfe4e1a943779a315e6ad3 --- /dev/null +++ b/calendario.py @@ -0,0 +1,708 @@ + +# -*- coding: utf-8 -*- +import streamlit as st +from datetime import date, datetime, timedelta +from typing import Dict, List +from banco import SessionLocal +from models import EventoCalendario +from utils_permissoes import verificar_permissao +from log import registrar_log +from utils_datas import formatar_data_br + +# ⬇️ Componente de calendário +from streamlit_calendar import calendar + +# ===================================================== +# 📅 CALENDÁRIO + CRONOGRAMA ANUAL (D-3 / D-2 / D-1 / D 🚢) +# ===================================================== + +# ------------------------------ +# ⚙️ Regras de embarque (fase/seed e passo) +# ------------------------------ +# seed_day = dia (de Janeiro) usado como "D" inicial para o ano selecionado +# step = dias entre embarques (D → próximo D) +REGRAS_FPSO = { + "ATD": {"seed_day": 1, "step": 5}, + "ADG": {"seed_day": 1, "step": 5}, + "CDM": {"seed_day": 2, "step": 5}, + "CDP": {"seed_day": 2, "step": 5}, + "CDS": {"seed_day": 2, "step": 5}, + "CDI": {"seed_day": 5, "step": 5}, # (ACDI → CDI) + "CDA": {"seed_day": 5, "step": 5}, + "SEP": {"seed_day": 4, "step": 4}, # sem dia vazio + "ESS": {"seed_day": 3, "step": 7}, # blocos com pausa maior +} + +# 🎨 Paleta +COLOR_MAP = { + "D-3": "#00B050", # verde + "D-2": "#FF0000", # vermelho + "D-1": "#C00000", # vermelho escuro + "D": "#7F7F7F", # cinza +} + +EMOJI_NAVIO = " 🚢" # adicionado aos títulos no dia D + + +def _usuario_atual() -> str: + return (st.session_state.get("usuario") or "sistema") + + +def _criar_evento_fc(title: str, dt: date, color: str, extra: Dict = None) -> dict: + """Monta um evento no formato FullCalendar/streamlit_calendar.""" + ev = { + "id": f"auto::{title}::{dt.isoformat()}", + "title": title, + "start": dt.isoformat(), + "allDay": True, + "color": color, + "extendedProps": {"gerado_auto": True}, + } + if extra: + ev["extendedProps"].update(extra) + return ev + + +def _rotulo_antes_de_d(dias: int) -> str: + """Converte o deslocamento até D para rótulo: 0->D, 1->D-1, 2->D-2, 3->D-3, outros->''""" + if dias == 0: + return "D" + if dias in (1, 2, 3): + return f"D-{dias}" + return "" + + +def _gerar_cronograma_ano( + ano: int, + fpsos_sel: List[str], + incluir_anteriores: bool = True, + apenas_D: bool = False, +) -> List[dict]: + """ + Gera eventos 'D-3/D-2/D-1/D 🚢' para TODO o ano. + - incluir_anteriores: inclui D-1..D-3 que caem no começo do ano (vindo do D-semente). + - apenas_D: se True, somente 'D 🚢'. + """ + events = [] + dt_ini = date(ano, 1, 1) + dt_fim = date(ano, 12, 31) + + for fpso in fpsos_sel: + cfg = REGRAS_FPSO.get(fpso) + if not cfg: + continue + seed_day = max(1, min(cfg["seed_day"], 28)) # segurança (fev) + seed = date(ano, 1, seed_day) + step = int(cfg["step"]) + + # Todos os D do ano + d = seed + while d <= dt_fim: + if d >= dt_ini: + # D (com emoji) + cor + titulo_d = f"{fpso} – D{EMOJI_NAVIO}" + events.append( + _criar_evento_fc( + titulo_d, d, COLOR_MAP["D"], + {"tipo": "D", "fpso": fpso} + ) + ) + if not apenas_D: + # D-1..D-3 + for k in (1, 2, 3): + dk = d - timedelta(days=k) + if dt_ini <= dk <= dt_fim: + label = f"D-{k}" + events.append( + _criar_evento_fc( + f"{fpso} – {label}", + dk, + COLOR_MAP[label], + {"tipo": label, "fpso": fpso}, + ) + ) + d += timedelta(days=step) + + # Cobertura no início do ano (apenas rótulos anteriores ao D-semente) + if incluir_anteriores and not apenas_D: + for k in (1, 2, 3): + dk = seed - timedelta(days=k) + if dt_ini <= dk <= dt_fim: + label = f"D-{k}" + events.append( + _criar_evento_fc( + f"{fpso} – {label}", + dk, + COLOR_MAP[label], + {"tipo": label, "fpso": fpso}, + ) + ) + return events + + +def _gerar_cronograma_intervalo( + ano_ini: int, + ano_fim: int, + fpsos_sel: List[str], + apenas_D: bool = False, +) -> List[dict]: + """Gera eventos para [ano_ini..ano_fim].""" + out = [] + for y in range(ano_ini, ano_fim + 1): + out.extend(_gerar_cronograma_ano(y, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D)) + return out + + +def _titulo_normalizado(titulo: str) -> str: + """Remove o emoji ' 🚢' apenas para comparação/deduplicação.""" + return titulo.replace(EMOJI_NAVIO, "") + + +def _dedup_chave(titulo: str, data_evt: date) -> str: + """Chave de de-duplicação (título normalizado + data).""" + return f"{_titulo_normalizado(titulo)}::{data_evt.isoformat()}" + + +def _gravar_cronograma_no_banco(db, eventos_fc: List[dict]) -> int: + """ + Grava no banco eventos 'gerado_auto' evitando duplicados (ignorando emoji). + Retorna contagem de inserções. + """ + # Pré-carregar existentes no intervalo abrangido + if not eventos_fc: + return 0 + min_day = min(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc) + max_day = max(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc) + + existentes = ( + db.query(EventoCalendario) + .filter(EventoCalendario.data_evento >= min_day) + .filter(EventoCalendario.data_evento <= max_day) + .filter(EventoCalendario.ativo.is_(True)) + .all() + ) + idx_existentes = { + _dedup_chave(e.titulo, e.data_evento): e.id for e in existentes + } + + ins = 0 + for ev in eventos_fc: + if not ev.get("extendedProps", {}).get("gerado_auto"): + continue + titulo = ev["title"] + dt = date.fromisoformat(ev["start"][:10]) + k = _dedup_chave(titulo, dt) + if k in idx_existentes: + continue + novo = EventoCalendario( + titulo=titulo, # mantém o emoji nos D + descricao=f"Cronograma automático ({ev['extendedProps'].get('tipo','')})", + data_evento=dt, + data_lembrete=None, + ativo=True, + usuario_criacao=_usuario_atual(), + data_criacao=datetime.now(), + ) + db.add(novo) + try: + db.commit() + ins += 1 + except Exception: + db.rollback() + return ins + + +def _remover_cronograma_do_banco_intervalo(db, fpsos_sel: List[str], ano_ini: int, ano_fim: int) -> int: + """ + Remove do banco os eventos gerados por este módulo, para [ano_ini..ano_fim] e FPSOs. + Busca por títulos (' – D' / ' – D 🚢' / ' – D-1/2/3') e data no intervalo. + """ + ini = date(ano_ini, 1, 1) + fim = date(ano_fim, 12, 31) + total = 0 + for fpso in fpsos_sel: + base = [f"{fpso} – D", f"{fpso} – D-1", f"{fpso} – D-2", f"{fpso} – D-3"] + # inclui com emoji para D + variantes = base + [f"{fpso} – D{EMOJI_NAVIO}"] + to_del = ( + db.query(EventoCalendario) + .filter(EventoCalendario.data_evento >= ini) + .filter(EventoCalendario.data_evento <= fim) + .filter(EventoCalendario.titulo.in_(variantes)) + .all() + ) + for e in to_del: + db.delete(e) + total += 1 + try: + db.commit() + except Exception: + db.rollback() + return total + + +def main(): + + # ===================================================== + # 🔒 PROTEÇÃO POR PERFIL + # ===================================================== + if not verificar_permissao("calendario"): + st.error("⛔ Acesso não autorizado.") + return + + st.title("📅 Calendário e Lembretes") + + hoje = date.today() + db = SessionLocal() + + # Helper: cor por status (eventos do banco) + def _cor_evento_db(e: "EventoCalendario") -> str: + if not e.ativo: + return "#95a5a6" # Cinza + if e.data_evento < hoje: + return "#e74c3c" # Vermelho (passado) + if e.data_lembrete and e.data_lembrete == hoje: + return "#f39c12" # Laranja (lembrete hoje) + return "#2ecc71" # Verde (ativo futuro) + + # Converte EventoCalendario do banco → FullCalendar + def _to_fc_event_db(e: "EventoCalendario") -> dict: + return { + "id": str(e.id), + "title": e.titulo, + "start": e.data_evento.isoformat(), + "allDay": True, + "color": _cor_evento_db(e), + "extendedProps": { + "descricao": (e.descricao or ""), + "data_evento": e.data_evento.isoformat(), + "data_lembrete": e.data_lembrete.isoformat() if e.data_lembrete else None, + "ativo": e.ativo, + "gerado_auto": False, + }, + } + + try: + # ===================================================== + # 🔔 LEMBRETES DO DIA + # ===================================================== + st.subheader("⏰ Lembretes de Hoje | Adicione o calendário da sua embarcação") + + lembretes = ( + db.query(EventoCalendario) + .filter(EventoCalendario.data_lembrete == hoje) + .filter(EventoCalendario.ativo.is_(True)) + .order_by(EventoCalendario.data_evento) + .all() + ) + + if lembretes: + for l in lembretes: + st.warning(f"🔔 **{l.titulo}** — Evento em {formatar_data_br(l.data_evento)}") + else: + st.info("Nenhum lembrete para hoje.") + + st.divider() + + # ===================================================== + # 🎛️ CONTROLES DO CRONOGRAMA + # ===================================================== + st.subheader("🛠️ Cronograma de Embarques (D-3 / D-2 / D-1 / D 🚢)") + + col_a, col_b, col_c = st.columns([1, 2, 2]) + with col_a: + ano_sel = st.number_input( + "Ano", + min_value=2000, max_value=2100, + value=hoje.year, step=1, key="cal_ano_sel" + ) + + fpsos_all = list(REGRAS_FPSO.keys()) + with col_b: + fpsos_sel = st.multiselect( + "FPSOs", + options=fpsos_all, + default=fpsos_all, + key="cal_fpsos_sel", + ) + if not fpsos_sel: + fpsos_sel = fpsos_all + + with col_c: + apenas_D = st.checkbox("Exibir apenas dias de Embarque (D)", value=False) + + # Gera cronograma em memória para o ANO selecionado (visualização) + eventos_auto = _gerar_cronograma_ano( + ano_sel, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D + ) + + # 🔁 Ações de banco: ANO + col_b1, col_b2, col_b3, col_b4 = st.columns([1.7, 1.7, 2, 2]) + with col_b1: + if st.button("💾 Gravar cronograma (ano) no banco"): + qtd = _gravar_cronograma_no_banco(db, eventos_auto) + if qtd > 0: + registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", None) + st.success(f"Cronograma do ano {ano_sel} gravado/atualizado. Inserções: {qtd}.") + st.rerun() + with col_b2: + if st.button("🧹 Remover cronograma (ano) do banco"): + qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, ano_sel) + if qtd > 0: + registrar_log(_usuario_atual(), "EXCLUIR", "eventos_calendario", None) + st.warning(f"Eventos removidos do banco (ano {ano_sel}): {qtd}.") + st.rerun() + + # 🔁 Ações de banco: INTERVALO ATÉ 2030 + with col_b3: + if st.button("💾 Gravar cronograma até 2030 (banco)"): + eventos_lote = _gerar_cronograma_intervalo( + ano_ini=ano_sel, ano_fim=2030, fpsos_sel=fpsos_sel, apenas_D=apenas_D + ) + qtd = _gravar_cronograma_no_banco(db, eventos_lote) + if qtd > 0: + registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", None) + st.success(f"Cronogramas {ano_sel}–2030 gravados/atualizados. Inserções: {qtd}.") + st.rerun() + with col_b4: + if st.button("🧹 Remover cronograma até 2030 (banco)"): + qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, 2030) + if qtd > 0: + registrar_log(_usuario_atual(), "EXCLUIR", "eventos_calendario", None) + st.warning(f"Eventos removidos do banco ({ano_sel}–2030): {qtd}.") + st.rerun() + + st.caption( + "• A geração automática **não** altera seus eventos manuais. " + "Use os botões para **gravar** ou **remover** do banco apenas os eventos criados por este módulo. " + "Nos dias de **D**, o título inclui o ícone de navio (🚢)." + ) + + st.divider() + + # ===================================================== + # ➕ NOVO EVENTO / LEMBRETE (manual) + # ===================================================== + with st.expander("➕ Novo Evento / Lembrete"): + with st.form("form_evento"): + titulo = st.text_input("Título *") + descricao = st.text_area("Descrição") + data_evento = st.date_input("Data do Evento", value=hoje, format="DD/MM/YYYY") + data_lembrete = st.date_input("Data do Lembrete (opcional)", value=None, format="DD/MM/YYYY") + ativo = st.checkbox("Evento ativo", value=True) + salvar = st.form_submit_button("💾 Salvar Evento") + + if salvar: + if not titulo.strip(): + st.error("⚠️ O título é obrigatório.") + elif data_lembrete and (data_lembrete > data_evento): + st.error("⚠️ O lembrete não pode ser após a data do evento.") + else: + evento = EventoCalendario( + titulo=titulo.strip(), + descricao=(descricao or "").strip(), + data_evento=data_evento, + data_lembrete=data_lembrete, + ativo=ativo, + usuario_criacao=_usuario_atual(), + data_criacao=datetime.now() + ) + db.add(evento) + try: + db.commit() + except Exception as e: + db.rollback() + st.error(f"❌ Erro ao salvar evento: {e}") + else: + registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", evento.id) + st.success("✅ Evento criado com sucesso!") + st.rerun() + + st.divider() + + # ===================================================== + # 📆 CALENDÁRIO (eventos do banco + cronograma do ANO selecionado) + # ===================================================== + st.subheader("📆 Calendário (clique no dia ou no evento para ver a observação)") + + # Banco (apenas ano selecionado na visualização) + ini_year = date(ano_sel, 1, 1) + end_year = date(ano_sel, 12, 31) + eventos_db = ( + db.query(EventoCalendario) + .filter(EventoCalendario.data_evento >= ini_year) + .filter(EventoCalendario.data_evento <= end_year) + .order_by(EventoCalendario.data_evento.asc()) + .all() + ) + eventos_fc_db = [_to_fc_event_db(e) for e in eventos_db] + + # Junta cronograma automático (memória) + banco (para a visualização do ano) + eventos_fc = eventos_fc_db + eventos_auto + + options = { + "initialView": "dayGridMonth", + "locale": "pt-br", + "height": 700, + "firstDay": 1, + "weekNumbers": False, + "headerToolbar": { + "left": "prev,next today", + "center": "title", + "right": "dayGridMonth,dayGridWeek,listWeek" + }, + "buttonText": { + "today": "Hoje", + "month": "Mês", + "week": "Semana", + "day": "Dia", + "list": "Lista" + }, + "dayMaxEventRows": True, + "navLinks": True, + } + + state = calendar( + events=eventos_fc, + options=options, + custom_css="", + key=f"calendario_eventos_{ano_sel}" + ) + + # Legenda + with st.container(): + cols = st.columns([1.2, 1.2, 1.2, 1.2, 2.2, 3]) + cols[0].markdown("⬛ **D** (cinza) " + EMOJI_NAVIO) + cols[1].markdown("🟥 **D‑1** (vinho)") + cols[2].markdown("🟥 **D‑2** (vermelho)") + cols[3].markdown("🟩 **D‑3** (verde)") + cols[4].markdown("🟧 **Lembrete hoje (eventos do banco)**") + cols[5].markdown("🟦 **Outros eventos (banco)**") + + st.divider() + + # ===================================================== + # 🔎 Detalhe por clique (evento ou dia) + # ===================================================== + clicked_event = None + if state and isinstance(state, dict): + clicked_event = (state.get("eventClick") or {}).get("event") + clicked_date_str = (state.get("dateClick") or {}).get("dateStr") + else: + clicked_date_str = None + + if clicked_event: + ev_id = clicked_event.get("id") + ev_title = clicked_event.get("title") + ev_start = clicked_event.get("start") + ev_ext = clicked_event.get("extendedProps") or {} + + # Se for do banco, traz detalhes atualizados + e = None + if ev_id and not str(ev_id).startswith("auto::"): + try: + e = db.query(EventoCalendario).get(int(ev_id)) + except Exception: + e = None + + st.subheader(f"📌 {ev_title or 'Evento'}") + if e: + st.markdown( + f""" +**Descrição:** +{e.descricao or "_Sem descrição_"} + +**📅 Data do Evento:** {formatar_data_br(e.data_evento)} +**⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete) if e.data_lembrete else "_Sem lembrete_"} +**📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"} +""" + ) + if verificar_permissao("administracao"): + col1, col2 = st.columns(2) + with col1: + if e.ativo and st.button("🚫 Desativar", key=f"desativar_{e.id}"): + e.ativo = False + try: + db.commit() + except Exception as ex: + db.rollback() + st.error(f"Erro ao desativar: {ex}") + else: + registrar_log(_usuario_atual(), "DESATIVAR", + "eventos_calendario", e.id) + st.success("Evento desativado.") + st.rerun() + with col2: + if st.button("🗑️ Excluir", key=f"excluir_{e.id}"): + db.delete(e) + try: + db.commit() + except Exception as ex: + db.rollback() + st.error(f"Erro ao excluir: {ex}") + else: + registrar_log(_usuario_atual(), "EXCLUIR", + "eventos_calendario", e.id) + st.success("Evento excluído.") + st.rerun() + else: + # Evento do cronograma automático (memória) + dt_evt = date.fromisoformat(ev_start[:10]) + st.markdown( + f""" +**FPSO:** {ev_title.split(' – ')[0] if ' – ' in (ev_title or '') else '—'} +**Tipo:** {ev_ext.get('tipo', '—')} +**📅 Data:** {formatar_data_br(dt_evt)} +**Origem:** _Cronograma automático (não gravado no banco)_ +""" + ) + + elif clicked_date_str: + try: + data_clicada = date.fromisoformat(clicked_date_str) + except Exception: + data_clicada = None + + if data_clicada: + st.subheader(f"🗓️ Eventos em {formatar_data_br(data_clicada)}") + + # Banco + eventos_no_dia_db = ( + db.query(EventoCalendario) + .filter(EventoCalendario.data_evento == data_clicada) + .order_by(EventoCalendario.id.desc()) + .all() + ) + if not eventos_no_dia_db: + st.info("Nenhum evento do banco para este dia.") + else: + st.markdown("**📦 Eventos do banco**") + for e in eventos_no_dia_db: + with st.expander(f"📌 {e.titulo}"): + st.markdown( + f""" +**Descrição:** +{e.descricao or "_Sem descrição_"} + +**📅 Data do Evento:** {formatar_data_br(e.data_evento)} +**⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete)} +**📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"} +""" + ) + if verificar_permissao("administracao"): + c1, c2 = st.columns(2) + with c1: + if e.ativo and st.button("🚫 Desativar", key=f"desativar_list_{e.id}"): + e.ativo = False + try: + db.commit() + except Exception as ex: + db.rollback() + st.error(f"Erro ao desativar: {ex}") + else: + registrar_log(_usuario_atual(), "DESATIVAR", + "eventos_calendario", e.id) + st.success("Evento desativado.") + st.rerun() + with c2: + if st.button("🗑️ Excluir", key=f"excluir_list_{e.id}"): + db.delete(e) + try: + db.commit() + except Exception as ex: + db.rollback() + st.error(f"Erro ao excluir: {ex}") + else: + registrar_log(_usuario_atual(), "EXCLUIR", + "eventos_calendario", e.id) + st.success("Evento excluído.") + st.rerun() + + # Cronograma automático (memória) – ano selecionado + eventos_auto_no_dia = [ + ev for ev in eventos_auto + if ev.get("start", "")[:10] == data_clicada.isoformat() + ] + if eventos_auto_no_dia: + st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**") + for ev in sorted(eventos_auto_no_dia, key=lambda x: x.get("title","")): + fps = ev.get("title","").split(" – ")[0] if " – " in ev.get("title","") else "—" + tipo = ev.get("extendedProps", {}).get("tipo", "—") + st.write(f"• **{fps}** — **{tipo}** ({formatar_data_br(data_clicada)})") + + st.divider() + + # ===================================================== + # 📆 Consultar Eventos por Data (modo antigo) — inclui AUTO + # ===================================================== + with st.expander("📆 Consultar Eventos por Data (modo antigo)"): + data_consulta = st.date_input("Selecione uma data", + value=hoje, format="DD/MM/YYYY", + key="consulta_antiga") + + # Banco + eventos = ( + db.query(EventoCalendario) + .filter(EventoCalendario.data_evento == data_consulta) + .order_by(EventoCalendario.id.desc()) + .all() + ) + if not eventos: + st.info("Nenhum evento do banco para esta data.") + else: + st.markdown("**📦 Eventos do banco**") + for e in eventos: + with st.expander(f"📌 {e.titulo}"): + st.markdown( + f""" +**Descrição:** +{e.descricao or "_Sem descrição_"} + +**📅 Data do Evento:** {formatar_data_br(e.data_evento)} +**⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete)} +**📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"} +""" + ) + if verificar_permissao("administracao"): + col1, col2 = st.columns(2) + with col1: + if e.ativo and st.button("🚫 Desativar", key=f"desativar_old_{e.id}"): + e.ativo = False + try: + db.commit() + except Exception as ex: + db.rollback() + st.error(f"Erro ao desativar: {ex}") + else: + registrar_log(_usuario_atual(), "DESATIVAR", + "eventos_calendario", e.id) + st.success("Evento desativado.") + st.rerun() + with col2: + if st.button("🗑️ Excluir", key=f"excluir_old_{e.id}"): + db.delete(e) + try: + db.commit() + except Exception as ex: + db.rollback() + st.error(f"Erro ao excluir: {ex}") + else: + registrar_log(_usuario_atual(), "EXCLUIR", + "eventos_calendario", e.id) + st.success("Evento excluído.") + st.rerun() + + # AUTO (memória) no ano selecionado + eventos_auto_antigo = [ + ev for ev in eventos_auto + if ev.get("start", "")[:10] == data_consulta.isoformat() + ] + if eventos_auto_antigo: + st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**") + for ev in sorted(eventos_auto_antigo, key=lambda x: x.get("title","")): + fps = ev.get("title","").split(" – ")[0] if " – " in ev.get("title","") else "—" + tipo = ev.get("extendedProps", {}).get("tipo", "—") + st.write(f"• **{fps}** — **{tipo}** ({formatar_data_br(data_consulta)})") + + finally: + db.close() diff --git a/calendario_mensal.py b/calendario_mensal.py new file mode 100644 index 0000000000000000000000000000000000000000..b5a2ca849138a7e1cebe8f2067dc9f3776a7a5a6 --- /dev/null +++ b/calendario_mensal.py @@ -0,0 +1,70 @@ +import streamlit as st +import calendar +from datetime import date +from banco import SessionLocal +from models import EventoCalendario +from utils_permissoes import verificar_permissao +from utils_datas import formatar_data_br + + +def main(): + + if not verificar_permissao("calendario"): + st.error("⛔ Acesso não autorizado.") + return + + usuario = st.session_state.get("usuario") + if not usuario: + st.error("Usuário não autenticado.") + return + + st.title("📆 Agenda Mensal") + + hoje = date.today() + + col1, col2 = st.columns(2) + + with col1: + ano = st.selectbox("Ano", range(hoje.year - 2, hoje.year + 3), index=2) + + with col2: + mes = st.selectbox("Mês", range(1, 13), index=hoje.month - 1) + + db = SessionLocal() + try: + eventos = ( + db.query(EventoCalendario) + .filter(EventoCalendario.usuario_criacao == usuario) + .filter(EventoCalendario.data_evento.between( + date(ano, mes, 1), + date(ano, mes, calendar.monthrange(ano, mes)[1]) + )) + .filter(EventoCalendario.ativo.is_(True)) + .all() + ) + finally: + db.close() + + eventos_por_dia = {} + for e in eventos: + eventos_por_dia.setdefault(e.data_evento.day, []).append(e) + + st.divider() + + semanas = calendar.monthcalendar(ano, mes) + + dias_semana = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"] + st.columns(7) + for d in dias_semana: + st.markdown(f"**{d}**") + + for semana in semanas: + cols = st.columns(7) + for idx, dia in enumerate(semana): + with cols[idx]: + if dia == 0: + st.write("") + else: + st.markdown(f"### {dia}") + for ev in eventos_por_dia.get(dia, []): + st.caption(f"📌 {ev.titulo}") diff --git a/componentes.py b/componentes.py new file mode 100644 index 0000000000000000000000000000000000000000..f41045b82b857a7acc04b576682e86ddf3fc1dc0 --- /dev/null +++ b/componentes.py @@ -0,0 +1,35 @@ +import streamlit as st + + +FPSO_PADRAO = [ + "CDA", + "CDP", + "CDM", + "ADG", + "ESS", + "SEP", + "CDI", + "ATD", + "CDS" +] + + +def campo_fpso(label, key): + """ + Campo FPSO com sugestões + opção de texto livre + """ + opcoes = [""] + FPSO_PADRAO + ["Outro"] + + escolha = st.selectbox( + label, + opcoes, + key=f"{key}_select" + ) + + if escolha == "Outro": + return st.text_input( + f"{label} (digite)", + key=f"{key}_texto" + ).strip() + + return escolha diff --git a/consulta.py b/consulta.py new file mode 100644 index 0000000000000000000000000000000000000000..fc47c7bc454349ed805c32f33e142d5201127404 --- /dev/null +++ b/consulta.py @@ -0,0 +1,277 @@ + +import streamlit as st +import pandas as pd +from io import BytesIO +from datetime import date +from banco import SessionLocal +from models import Equipamento + + +def limpar_estado_consulta(): + """ + Remove do session_state qualquer dado + relacionado ao módulo Consulta + """ + for key in list(st.session_state.keys()): + if key.startswith("consulta_"): + del st.session_state[key] + + +def _coerce_date(x): + """Garante que valores sejam datas (date) ou NaT para comparação.""" + if pd.isna(x): + return pd.NaT + if isinstance(x, (pd.Timestamp, )): + return x.date() + if isinstance(x, date): + return x + try: + return pd.to_datetime(x).date() + except Exception: + return pd.NaT + + +def main(): + + # ===================================================== + # 🧹 LIMPA ESTADO AO ENTRAR NO MÓDULO + # ===================================================== + if not st.session_state.get("_consulta_inicializado"): + limpar_estado_consulta() + st.session_state["_consulta_inicializado"] = True + + st.title("🔍 Consulta de Registros") + + db = SessionLocal() + + try: + registros = db.query(Equipamento).all() + + if not registros: + st.info("Nenhum registro encontrado.") + return + + # ===================================================== + # 🔄 CONVERTE REGISTROS EM DATAFRAME (TODOS OS CAMPOS) + # ===================================================== + df = pd.DataFrame([ + { + "ID": r.id, + + # Identificação + "FPSO1": r.fpso1, + "FPSO": r.fpso, + "Data Coleta": r.data_coleta, + + # Responsáveis + "Especialista": r.especialista, + "Conferente": r.conferente, + "OSM": r.osm, + + # Operacional + "Modal": r.modal, + "Quantidade Equip.": r.quant_equip, + "MROB": r.mrob, + + # Métricas + "Linhas OSM": r.linhas_osm, + "Linhas MROB": r.linhas_mrob, + "Linhas Erros": r.linhas_erros, + + # Erros + "Erro Storekeeper": r.erro_storekeeper, + "Erro Operação": r.erro_operacao, + "Erro Especialista": r.erro_especialista, + "Erro Outros": r.erro_outros, + + # Dados complementares + "Inclusão / Exclusão": r.inclusao_exclusao, + "PO": r.po, + "Part Number": r.part_number, + "Material": r.material, + + "Solicitante": r.solicitante, + "Motivo": getattr(r, "motivo", None), + "Requisitante": r.requisitante, + "Nota Fiscal": r.nota_fiscal, + "Impacto": r.impacto, + "Dimensão": r.dimensao, + + "Observações": r.observacoes, + "Dia Inclusão": r.dia_inclusao, + + # Auditoria + "Data/Hora Input": r.data_hora_input, + } + for r in registros + ]) + + # Normaliza a coluna de data para comparação correta + if "Data Coleta" in df.columns: + df["Data Coleta"] = df["Data Coleta"].apply(_coerce_date) + + # ===================================================== + # 🔎 FILTROS + # ===================================================== + st.subheader("🔎 Filtros") + + col1, col2, col3 = st.columns(3) + + with col1: + filtro_fpso = st.multiselect( + "FPSO", + sorted(df["FPSO"].dropna().unique()), + key="consulta_fpso" + ) + + filtro_dia = st.multiselect( + "Dia de Inclusão (D1 / D2 / D3)", + sorted(df["Dia Inclusão"].dropna().unique()), + key="consulta_dia" + ) + + with col2: + filtro_modal = st.multiselect( + "Modal", + sorted(df["Modal"].dropna().unique()), + key="consulta_modal" + ) + + filtro_especialista = st.multiselect( + "Especialista", + sorted(df["Especialista"].dropna().unique()), + key="consulta_especialista" + ) + + # 🔵 FILTRO OSM + filtro_osm = st.multiselect( + "OSM", + sorted(df["OSM"].dropna().unique()), + key="consulta_osm" + ) + + with col3: + periodo = st.date_input( + "Período de Coleta", + value=None, + key="consulta_periodo" + ) + + # 🟩 NOVO: FILTRO DE NOTA FISCAL + st.markdown("**Nota Fiscal**") + nota_input_text = st.text_input( + "Digite um ou mais números (separados por vírgula)", + value="", + key="consulta_nf_text" + ) + # Alternativamente (opcional) oferecer multiselect pelos valores existentes + filtro_nf_multi = st.multiselect( + "Ou selecione", + sorted([str(x) for x in df["Nota Fiscal"].dropna().unique()]), + key="consulta_nf_multi" + ) + mostrar_apenas_duplicadas = st.checkbox( + "Mostrar apenas notas duplicadas", + value=False, + key="consulta_mostrar_dup_nf" + ) + + # ===================================================== + # 🔄 APLICA FILTROS + # ===================================================== + # Filtros simples + if filtro_fpso: + df = df[df["FPSO"].isin(filtro_fpso)] + + if filtro_modal: + df = df[df["Modal"].isin(filtro_modal)] + + if filtro_especialista: + df = df[df["Especialista"].isin(filtro_especialista)] + + if filtro_dia: + df = df[df["Dia Inclusão"].isin(filtro_dia)] + + if filtro_osm: + df = df[df["OSM"].isin(filtro_osm)] + + # Filtro de período (intervalo) + if isinstance(periodo, (list, tuple)) and len(periodo) == 2 and all(periodo): + data_inicio, data_fim = periodo + df = df[ + (df["Data Coleta"] >= data_inicio) & + (df["Data Coleta"] <= data_fim) + ] + + # ----------------------------------------------------- + # Filtro de Nota Fiscal (texto e/ou multiselect) + # ----------------------------------------------------- + # Consolida as notas informadas via texto (separadas por vírgula) + notas_texto = [] + if nota_input_text.strip(): + notas_texto = [x.strip() for x in nota_input_text.split(",") if x.strip()] + + # Concatena com o multiselect (transformando em string) + notas_escolhidas = set([str(x) for x in filtro_nf_multi] + [str(x) for x in notas_texto]) + + if notas_escolhidas: + # Comparar sempre como string para evitar problemas com zeros à esquerda ou tipos heterogêneos + df = df[df["Nota Fiscal"].astype(str).isin(notas_escolhidas)] + + # ===================================================== + # 🧭 SINALIZA DUPLICIDADE DE NOTA FISCAL + # ===================================================== + # Conta ocorrências por número (string) ignorando NaN + nf_series = df["Nota Fiscal"].astype(str).fillna("") + contagem_nf = nf_series.value_counts(dropna=False) + # Duplicadas são as que tem contagem > 1 (e não vazias) + notas_duplicadas = contagem_nf[(contagem_nf > 1) & (contagem_nf.index != "")] + + # Coluna booleana marcando duplicidade no DF atual + df["Duplicidade Nota"] = df["Nota Fiscal"].astype(str).isin(notas_duplicadas.index) + + # Aviso resumido + if len(notas_duplicadas) > 0: + st.warning( + f"⚠️ Foram encontradas **{int(notas_duplicadas.sum())}** ocorrências em **{len(notas_duplicadas)}** " + f"números de Nota Fiscal duplicados no resultado." + ) + with st.expander("Ver lista de notas duplicadas"): + dup_df = pd.DataFrame({ + "Nota Fiscal": notas_duplicadas.index, + "Ocorrências": notas_duplicadas.values + }).sort_values(by="Ocorrências", ascending=False) + st.dataframe(dup_df, use_container_width=True) + + # Mostrar apenas duplicadas, caso marcado + if mostrar_apenas_duplicadas: + df = df[df["Duplicidade Nota"] == True] + + # ===================================================== + # 📊 RESULTADOS + # ===================================================== + st.subheader("📊 Resultados") + st.caption("A coluna **Duplicidade Nota** indica se há mais de um registro com o mesmo número de Nota Fiscal no resultado atual.") + st.dataframe(df, use_container_width=True) + + # ===================================================== + # 📥 EXPORTAÇÃO EXCEL + # ===================================================== + buffer = BytesIO() + with pd.ExcelWriter(buffer, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Consulta") + + buffer.seek(0) + + st.download_button( + label="⬇️ Exportar para Excel", + data=buffer, + file_name="consulta_equipamentos.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + + finally: + db.close() + + + diff --git a/db_admin.py b/db_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..b6d170b55da26ccefc611bc530dc993ec0ce533d --- /dev/null +++ b/db_admin.py @@ -0,0 +1,387 @@ + +# db_admin.py +import streamlit as st +import os +import shutil +from sqlalchemy import text +from banco import engine, SessionLocal +from utils_permissoes import verificar_permissao +from utils_auditoria import registrar_log + +# ===================================================== +# MÓDULO GERAL DE ADMINISTRAÇÃO DE BANCO (SCHEMA) +# ===================================================== +# Objetivo: +# - Permitir adicionar, renomear, excluir e alterar tipo de colunas via UI +# - Funciona com SQLite, PostgreSQL e MySQL (com diferenças por dialeto) +# - Em SQLite, oferece reconstrução assistida quando DDL não é suportado +# +# Segurança e boas práticas: +# - Recomendado fazer backup antes de operações (botão disponível para SQLite) +# - Operações DDL são críticas: exigir confirmação explícita +# - Acesso restrito ao perfil "admin" +# +# Logs: +# - registrar_log(...) é chamado em todas as operações + + +# ------------------------- +# Utilitário: Dialeto e versão +# ------------------------- +def _dialeto(): + try: + return engine.url.get_backend_name() + except Exception: + return "desconhecido" + +def _sqlite_version(): + if _dialeto() != "sqlite": + return None + try: + with engine.begin() as conn: + rv = conn.execute(text("select sqlite_version()")).scalar() + return rv + except Exception: + return None + + +# ------------------------- +# Utilitário: Listar tabelas e colunas +# ------------------------- +def _listar_tabelas(): + d = _dialeto() + with engine.begin() as conn: + if d == "sqlite": + rows = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")).fetchall() + return [r[0] for r in rows] + else: + q = text(""" + SELECT table_name + FROM information_schema.tables + WHERE table_schema NOT IN ('pg_catalog','information_schema') + ORDER BY table_name + """) + rows = conn.execute(q).fetchall() + return [r[0] for r in rows] + +def _listar_colunas(tabela: str): + d = _dialeto() + with engine.begin() as conn: + if d == "sqlite": + rows = conn.execute(text(f"PRAGMA table_info({tabela})")).fetchall() + # PRAGMA: (cid, name, type, notnull, dflt_value, pk) + return [{"name": r[1], "type": r[2], "notnull": bool(r[3]), "default": r[4], "pk": bool(r[5])} for r in rows] + else: + q = text(""" + SELECT column_name, data_type, is_nullable, column_default + FROM information_schema.columns + WHERE table_name = :tbl + ORDER BY ordinal_position + """) + rows = conn.execute(q, {"tbl": tabela}).fetchall() + return [{"name": r[0], "type": r[1], "notnull": (str(r[2]).upper() == "NO"), "default": r[3], "pk": False} for r in rows] + + +# ------------------------- +# Backup rápido (SQLite) +# ------------------------- +def _sqlite_backup(): + if _dialeto() != "sqlite": + st.info("Backup automático só disponível para SQLite via cópia de arquivo.") + return + db_path = engine.url.database + if not db_path or not os.path.exists(db_path): + st.error("Arquivo de banco SQLite não encontrado.") + return + dest = db_path + ".bak" + shutil.copyfile(db_path, dest) + st.success(f"Backup criado: {dest}") + + +# ------------------------- +# DDL: Gerar comandos por dialeto +# ------------------------- +def _ddl_add_column_sql(tabela, col_nome, col_tipo, notnull=False, default=None): + d = _dialeto() + nn = "NOT NULL" if notnull else "NULL" + def_clause = f" DEFAULT {default}" if (default is not None and str(default).strip() != "") else "" + if d == "sqlite": + # SQLite aceita tipo textual; notnull e default são respeitados no schema + return f"ALTER TABLE {tabela} ADD COLUMN {col_nome} {col_tipo} {nn}{def_clause};" + elif d in ("postgresql", "postgres"): + base = f'ALTER TABLE "{tabela}" ADD COLUMN "{col_nome}" {col_tipo}' + if default is not None and str(default).strip() != "": + base += f" DEFAULT {default}" + if notnull: + base += " NOT NULL" + return base + ";" + elif d in ("mysql", "mariadb"): + base = f"ALTER TABLE `{tabela}` ADD COLUMN `{col_nome}` {col_tipo}" + if default is not None and str(default).strip() != "": + base += f" DEFAULT {default}" + base += " NOT NULL" if notnull else " NULL" + return base + ";" + return None + +def _ddl_rename_column_sql(tabela, old, new): + d = _dialeto() + if d == "sqlite": + return f"ALTER TABLE {tabela} RENAME COLUMN {old} TO {new};" + elif d in ("postgresql", "postgres"): + return f'ALTER TABLE "{tabela}" RENAME COLUMN "{old}" TO "{new}";' + elif d in ("mysql", "mariadb"): + # MySQL requer tipo na renomeação; esta função não cobre tipo -> usar CHANGE COLUMN via UI de "Alterar tipo/renomear" + return None + return None + +def _ddl_drop_column_sql(tabela, col): + d = _dialeto() + if d == "sqlite": + return f"ALTER TABLE {tabela} DROP COLUMN {col};" + elif d in ("postgresql", "postgres"): + return f'ALTER TABLE "{tabela}" DROP COLUMN "{col}";' + elif d in ("mysql", "mariadb"): + return f"ALTER TABLE `{tabela}` DROP COLUMN `{col}`;" + return None + +def _ddl_alter_type_sql(tabela, col, new_type): + d = _dialeto() + if d == "sqlite": + # SQLite não altera type declarado via ALTER TYPE. Necessário reconstruir tabela. + return None + elif d in ("postgresql", "postgres"): + return f'ALTER TABLE "{tabela}" ALTER COLUMN "{col}" TYPE {new_type};' + elif d in ("mysql", "mariadb"): + return f"ALTER TABLE `{tabela}` MODIFY COLUMN `{col}` {new_type};" + return None + + +# ------------------------- +# Reconstrução assistida (SQLite) +# ------------------------- +def _sqlite_reconstruir_tabela(tabela, novas_colunas): + """ + Reconstrói tabela SQLite com "novas_colunas" (lista de dicts): + [{"name":..., "type":..., "notnull":bool, "default":..., "pk":bool}, ...] + - Cria tabela __tmp_ com o novo schema + - Copia dados das colunas compatíveis (mesmos nomes) + - Drop da tabela original e rename da temporária + """ + cols_def = [] + copy_cols = [] + pk_cols = [c["name"] for c in novas_colunas if c.get("pk")] + for c in novas_colunas: + nn = "NOT NULL" if c.get("notnull") else "" + default = c.get("default") + def_clause = f" DEFAULT {default}" if (default is not None and str(default).strip() != "") else "" + cols_def.append(f'{c["name"]} {c["type"]} {nn}{def_clause}'.strip()) + copy_cols.append(c["name"]) + pk_clause = f", PRIMARY KEY ({', '.join(pk_cols)})" if pk_cols else "" + + create_sql = f"CREATE TABLE __tmp_{tabela} ({', '.join(cols_def)}{pk_clause});" + copy_sql = f"INSERT INTO __tmp_{tabela} ({', '.join(copy_cols)}) SELECT {', '.join(copy_cols)} FROM {tabela};" + drop_sql = f"DROP TABLE {tabela};" + rename_sql= f"ALTER TABLE __tmp_{tabela} RENAME TO {tabela};" + + with engine.begin() as conn: + conn.execute(text(create_sql)) + conn.execute(text(copy_sql)) + conn.execute(text(drop_sql)) + conn.execute(text(rename_sql)) + + +# ------------------------- +# UI principal (admin) +# ------------------------- +def main(): + st.title("🛠️ Administração de Banco (Schema)") + + # 🔐 Proteção por perfil + if not verificar_permissao("db_admin") and st.session_state.get("perfil") != "admin": + st.error("⛔ Acesso não autorizado.") + return + + # Info do banco + dial = _dialeto() + st.caption(f"Dialeto: **{dial}**") + ver = _sqlite_version() + if ver: + st.caption(f"SQLite version: **{ver}**") + + # Backup (SQLite) + if dial == "sqlite": + if st.button("💾 Backup rápido (SQLite)"): + _sqlite_backup() + + # Tabelas disponíveis + tabelas = _listar_tabelas() + if not tabelas: + st.warning("Nenhuma tabela encontrada.") + return + + tabela = st.selectbox("Tabela alvo:", tabelas, index=0) + colunas = _listar_colunas(tabela) + + st.divider() + st.subheader("📋 Colunas atuais") + st.write(pd.DataFrame(colunas)) if 'pd' in globals() else st.write(colunas) # mostra estrutura atual + + st.divider() + tabs = st.tabs(["➕ Adicionar coluna", "✏️ Renomear coluna", "🗑️ Excluir coluna", "♻️ Alterar tipo"]) + + # ----------------- Adicionar coluna ----------------- + with tabs[0]: + st.markdown("**Adicionar uma nova coluna à tabela selecionada**") + novo_nome = st.text_input("Nome da nova coluna") + novo_tipo = st.text_input("Tipo (ex.: TEXT, INTEGER, VARCHAR(255))") + novo_notnull = st.checkbox("NOT NULL", value=False) + novo_default = st.text_input("DEFAULT (opcional)") + + confirmar_add = st.checkbox("Confirmo a adição desta coluna (DDL).") + if st.button("Executar ADD COLUMN", type="primary") and confirmar_add: + sql = _ddl_add_column_sql(tabela, novo_nome, novo_tipo, notnull=novo_notnull, default=novo_default) + if not sql: + st.error("Dialeto não suportado para ADD COLUMN.") + else: + try: + with engine.begin() as conn: + conn.execute(text(sql)) + registrar_log(st.session_state.get("usuario"), f"ADD COLUMN {novo_nome} {novo_tipo} em {tabela}", "schema", None) + st.success("✅ Coluna adicionada com sucesso.") + st.rerun() + except Exception as e: + st.error(f"Erro ao adicionar coluna: {e}") + + # ----------------- Renomear coluna ----------------- + with tabs[1]: + st.markdown("**Renomear uma coluna existente**") + col_nomes = [c["name"] for c in colunas] + antigo = st.selectbox("Coluna atual:", col_nomes) if col_nomes else "" + novo = st.text_input("Novo nome da coluna") + + confirmar_ren = st.checkbox("Confirmo a renomeação desta coluna (DDL).") + if st.button("Executar RENAME COLUMN") and confirmar_ren: + d = _dialeto() + if d == "sqlite": + # Verifica suporte na versão + ver = _sqlite_version() or "0.0.0" + suportado = tuple(map(int, ver.split("."))) >= (3, 25, 0) + if suportado: + sql = _ddl_rename_column_sql(tabela, antigo, novo) + try: + with engine.begin() as conn: + conn.execute(text(sql)) + registrar_log(st.session_state.get("usuario"), f"RENAME COLUMN {antigo}→{novo} em {tabela}", "schema", None) + st.success("✅ Coluna renomeada com sucesso.") + st.rerun() + except Exception as e: + st.error(f"Erro ao renomear: {e}") + else: + st.warning("SQLite < 3.25 não suporta RENAME COLUMN. Oferecendo reconstrução assistida.") + # Reconstrução: atualiza metadados e recria tabela + novas = [] + for c in colunas: + nm = novo if c["name"] == antigo else c["name"] + novas.append({"name": nm, "type": c["type"], "notnull": c["notnull"], "default": c["default"], "pk": c["pk"]}) + try: + _sqlite_reconstruir_tabela(tabela, novas) + registrar_log(st.session_state.get("usuario"), f"RENAME (rebuild) {antigo}→{novo} em {tabela}", "schema", None) + st.success("✅ Reconstrução concluída com sucesso.") + st.rerun() + except Exception as e: + st.error(f"Erro na reconstrução: {e}") + elif d in ("postgresql", "postgres"): + sql = _ddl_rename_column_sql(tabela, antigo, novo) + if not sql: + st.error("Renomeação não suportada.") + else: + try: + with engine.begin() as conn: + conn.execute(text(sql)) + registrar_log(st.session_state.get("usuario"), f"RENAME COLUMN {antigo}→{novo} em {tabela}", "schema", None) + st.success("✅ Coluna renomeada com sucesso.") + st.rerun() + except Exception as e: + st.error(f"Erro ao renomear: {e}") + elif d in ("mysql", "mariadb"): + st.info("MySQL/MariaDB exigem 'CHANGE COLUMN' informando o novo tipo; use a aba 'Alterar tipo' para renomear junto com tipo.") + + # ----------------- Excluir coluna ----------------- + with tabs[2]: + st.markdown("**Excluir uma coluna existente**") + col_nomes = [c["name"] for c in colunas] + col_drop = st.selectbox("Coluna a excluir:", col_nomes) if col_nomes else "" + + confirmar_drop = st.checkbox("Confirmo a exclusão desta coluna (DDL) e entendo que é irreversível.") + if st.button("Executar DROP COLUMN", type="secondary") and confirmar_drop: + d = _dialeto() + if d == "sqlite": + ver = _sqlite_version() or "0.0.0" + suportado = tuple(map(int, ver.split("."))) >= (3, 35, 0) + if suportado: + sql = _ddl_drop_column_sql(tabela, col_drop) + try: + with engine.begin() as conn: + conn.execute(text(sql)) + registrar_log(st.session_state.get("usuario"), f"DROP COLUMN {col_drop} em {tabela}", "schema", None) + st.success("✅ Coluna excluída com sucesso.") + st.rerun() + except Exception as e: + st.error(f"Erro ao excluir: {e}") + else: + st.warning("SQLite < 3.35 não suporta DROP COLUMN. Oferecendo reconstrução assistida.") + novas = [c for c in colunas if c["name"] != col_drop] + try: + _sqlite_reconstruir_tabela(tabela, novas) + registrar_log(st.session_state.get("usuario"), f"DROP (rebuild) {col_drop} em {tabela}", "schema", None) + st.success("✅ Reconstrução concluída e coluna removida.") + st.rerun() + except Exception as e: + st.error(f"Erro na reconstrução: {e}") + elif d in ("postgresql", "postgres", "mysql", "mariadb"): + sql = _ddl_drop_column_sql(tabela, col_drop) + try: + with engine.begin() as conn: + conn.execute(text(sql)) + registrar_log(st.session_state.get("usuario"), f"DROP COLUMN {col_drop} em {tabela}", "schema", None) + st.success("✅ Coluna excluída com sucesso.") + st.rerun() + except Exception as e: + st.error(f"Erro ao excluir: {e}") + + # ----------------- Alterar tipo ----------------- + with tabs[3]: + st.markdown("**Alterar tipo declarado de uma coluna**") + col_nomes = [c["name"] for c in colunas] + alvo = st.selectbox("Coluna alvo:", col_nomes) if col_nomes else "" + novo_tipo = st.text_input("Novo tipo (ex.: TEXT, INTEGER, VARCHAR(255))") + + confirmar_type = st.checkbox("Confirmo a alteração de tipo (DDL).") + if st.button("Executar ALTER TYPE") and confirmar_type: + d = _dialeto() + if d == "sqlite": + st.warning("SQLite não suporta ALTER TYPE direto; oferecemos reconstrução assistida.") + novas = [] + for c in colunas: + typ = novo_tipo if c["name"] == alvo else c["type"] + novas.append({"name": c["name"], "type": typ, "notnull": c["notnull"], "default": c["default"], "pk": c["pk"]}) + try: + _sqlite_reconstruir_tabela(tabela, novas) + registrar_log(st.session_state.get("usuario"), f"ALTER TYPE (rebuild) {alvo}→{novo_tipo} em {tabela}", "schema", None) + st.success("✅ Tipo alterado com sucesso via reconstrução.") + st.rerun() + except Exception as e: + st.error(f"Erro na reconstrução: {e}") + elif d in ("postgresql", "postgres", "mysql", "mariadb"): + sql = _ddl_alter_type_sql(tabela, alvo, novo_tipo) + if not sql: + st.error("Dialeto não suportado para ALTER TYPE.") + else: + try: + with engine.begin() as conn: + conn.execute(text(sql)) + registrar_log(st.session_state.get("usuario"), f"ALTER TYPE {alvo}→{novo_tipo} em {tabela}", "schema", None) + st.success("✅ Tipo alterado com sucesso.") + st.rerun() + except Exception as e: + st.error(f"Erro ao alterar tipo: {e}") diff --git a/db_export_import.py b/db_export_import.py new file mode 100644 index 0000000000000000000000000000000000000000..89e75e544c4de74bb1d57dd106ee0f44e344bf1e --- /dev/null +++ b/db_export_import.py @@ -0,0 +1,359 @@ + +# -*- coding: utf-8 -*- +""" +db_export_import.py — Backup & Restore (Export/Import) do banco ATIVO (Produção/Teste) + +Recursos: +• Exibe banco ativo (prod/test) e URL do engine +• Exporta todas as tabelas para: + - ZIP (CSV por tabela + manifest.json) + - Excel (.xlsx) (1 aba por tabela + manifest sheet) +• Importa (upload) de: + - ZIP (CSV por tabela) + - Excel (.xlsx) +• Modos de import: APPEND ou REPLACE (cuidado com FK) +• Snapshot físico para SQLite: cópia do arquivo (.db) — backup/restore rápido + +Dependências: +- pandas, openpyxl, sqlalchemy, zipfile, io, json, datetime +""" + +import os +import io +import json +import zipfile +from datetime import datetime + +import streamlit as st +import pandas as pd +from sqlalchemy import inspect, text + +from banco import get_engine, db_info, SessionLocal +from utils_auditoria import registrar_log + +# Ambiente (prod/test) — se db_router não existir, fallback para 'prod' +try: + from db_router import current_db_choice + _HAS_ROUTER = True +except Exception: + _HAS_ROUTER = False + + def current_db_choice() -> str: + return "prod" + + +# ========================= +# Helpers: tabelas e I/O +# ========================= +def list_tables(engine) -> list[str]: + """Retorna nomes de todas as tabelas via SQLAlchemy inspection.""" + inspector = inspect(engine) + return inspector.get_table_names() + + +def _read_table_df(engine, table_name: str) -> pd.DataFrame: + """Lê toda a tabela como DataFrame.""" + try: + # pandas + SQLAlchemy: lê tabela diretamente + return pd.read_sql_table(table_name, con=engine) + except Exception: + # fallback: SELECT com aspas (útil para SQLite com nomes case-sensitive) + return pd.read_sql(f'SELECT * FROM "{table_name}"', con=engine) + + +def _write_table_df(engine, table_name: str, df: pd.DataFrame, mode: str = "append"): + """ + Escreve DataFrame em tabela. + mode: "append" (adiciona) ou "replace" (sobrescreve todos os dados). + Observação: nos fluxos de import, quando 'replace' foi selecionado, + todas as tabelas são truncadas previamente, e aqui usamos 'append'. + """ + if mode not in ("append", "replace"): + mode = "append" + df.to_sql(table_name, con=engine, if_exists=mode, index=False) + + +# ========================= +# Export: ZIP (CSV) & Excel +# ========================= +def export_zip(engine, ambiente: str) -> bytes: + """ + Exporta todas as tabelas para um ZIP: + - 1 CSV por tabela (UTF-8-BOM) + - manifest.json com metadados (ambiente, timestamp, url, tabelas) + """ + tables = list_tables(engine) + buf = io.BytesIO() + with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z: + for t in tables: + df = _read_table_df(engine, t) + csv_bytes = df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig") + z.writestr(f"{t}.csv", csv_bytes) + + manifest = { + "ambiente": ambiente, + "timestamp": datetime.now().isoformat(), + "engine_url": str(engine.url), + "tables": tables, + "format": "zip/csv", + "version": "1.0", + } + z.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2)) + buf.seek(0) + return buf.getvalue() + + +def export_excel(engine, ambiente: str) -> bytes: + """ + Exporta todas as tabelas para um Excel (.xlsx): + - 1 aba por tabela (limitada a 31 caracteres) + - "manifest" com metadados + """ + tables = list_tables(engine) + buf = io.BytesIO() + with pd.ExcelWriter(buf, engine="openpyxl") as writer: + # manifest + manifest = pd.DataFrame([{ + "ambiente": ambiente, + "timestamp": datetime.now().isoformat(), + "engine_url": str(engine.url), + "tables": ", ".join(tables), + "format": "xlsx", + "version": "1.0", + }]) + manifest.to_excel(writer, sheet_name="manifest", index=False) + + # tabelas → 1 aba por tabela + for t in tables: + df = _read_table_df(engine, t) + sheet = t[:31] if len(t) > 31 else t + df.to_excel(writer, sheet_name=sheet, index=False) + buf.seek(0) + return buf.getvalue() + + +# ========================= +# Import: ZIP (CSV) & Excel +# ========================= +def import_zip(engine, file_bytes: bytes, mode: str = "append") -> dict: + """ + Importa dados de um ZIP (CSV por tabela). + mode: "append" ou "replace". + Retorna um relatório {table: {"rows": int, "mode": str}}. + """ + report = {} + zbuf = io.BytesIO(file_bytes) + with zipfile.ZipFile(zbuf, "r") as z: + # Se replace, limpar tabelas (cuidado com FK) + if mode == "replace": + _truncate_all(engine) + + for name in z.namelist(): + if not name.lower().endswith(".csv"): + continue + table = os.path.splitext(os.path.basename(name))[0] + csv_bytes = z.read(name) + df = pd.read_csv(io.BytesIO(csv_bytes), dtype=str) # dtype=str para evitar coercões agressivas + # Conversões leves de datetime (best-effort) + for col in df.columns: + if "data" in col.lower() or "date" in col.lower(): + try: + df[col] = pd.to_datetime(df[col], errors="ignore") + except Exception: + pass + # replace já truncou; aqui fazemos append + _write_table_df(engine, table, df, mode="append") + report[table] = {"rows": int(len(df)), "mode": mode} + return report + + +def import_excel(engine, file_bytes: bytes, mode: str = "append") -> dict: + """ + Importa dados de um Excel (.xlsx) com múltiplas abas (1 por tabela). + mode: "append" ou "replace". + """ + report = {} + xbuf = io.BytesIO(file_bytes) + xls = pd.ExcelFile(xbuf, engine="openpyxl") + sheets = [s for s in xls.sheet_names if s.lower() != "manifest"] + + if mode == "replace": + _truncate_all(engine) + + for sheet in sheets: + df = xls.parse(sheet_name=sheet, dtype=str) + # best-effort para datas + for col in df.columns: + if "data" in col.lower() or "date" in col.lower(): + try: + df[col] = pd.to_datetime(df[col], errors="ignore") + except Exception: + pass + table = sheet + _write_table_df(engine, table, df, mode="append") + report[table] = {"rows": int(len(df)), "mode": mode} + return report + + +# ========================= +# Truncate (REPLACE mode) +# ========================= +def _truncate_all(engine): + """ + Limpa todas as tabelas do banco ativo (cuidado!). + • Para SQLite: desabilita FK temporariamente, apaga, e reabilita. + • Para outros bancos: executa DELETE tabela; considere ordem por FK se necessário. + """ + insp = inspect(engine) + tables = insp.get_table_names() + + with engine.begin() as conn: + url = str(engine.url) + is_sqlite = url.startswith("sqlite") + if is_sqlite: + conn.execute(text("PRAGMA foreign_keys=OFF")) + + # Apaga conteúdo (sem considerar ordem de FK — OK para SQLite com FK OFF) + for t in tables: + conn.execute(text(f'DELETE FROM "{t}"')) + + if is_sqlite: + conn.execute(text("PRAGMA foreign_keys=ON")) + + +# ========================= +# Snapshot físico (SQLite) +# ========================= +def snapshot_sqlite(engine, ambiente: str) -> bytes: + """ + Cria um snapshot (cópia física) do arquivo SQLite do banco ativo. + Retorna o conteúdo do arquivo para download. + """ + url = str(engine.url) + if not url.startswith("sqlite:///"): + raise RuntimeError("Snapshot físico disponível apenas para SQLite.") + db_path = url.replace("sqlite:///", "") + if not os.path.isfile(db_path): + raise FileNotFoundError(f"Arquivo SQLite não encontrado: {db_path}") + + with open(db_path, "rb") as f: + data = f.read() + + # auditoria + try: + registrar_log(usuario=st.session_state.get("usuario"), + acao=f"Snapshot SQLite ({ambiente})", + tabela="backup", + registro_id=None) + except Exception: + pass + + return data + + +# ========================= +# UI (Streamlit) +# ========================= +def main(): + st.title("🗄️ Backup & Restore | Export/Import de Banco") + + # Banco ativo e info + ambiente = current_db_choice() + info = db_info() + st.caption(f"🧭 Ambiente: {'Produção' if ambiente == 'prod' else 'Teste'}") + st.caption(f"🔗 Engine URL: {info.get('url')}") + + engine = get_engine() + + st.divider() + st.subheader("⬇️ Exportar dados") + + colA, colB, colC = st.columns(3) + with colA: + if st.button("Exportar ZIP (CSV por tabela)", type="primary"): + try: + zip_bytes = export_zip(engine, ambiente) + fname = f"backup_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip" + st.download_button("📥 Baixar ZIP", data=zip_bytes, file_name=fname, mime="application/zip") + registrar_log(usuario=st.session_state.get("usuario"), + acao=f"Export ZIP (ambiente={ambiente})", + tabela="backup", registro_id=None) + except Exception as e: + st.error(f"Falha ao exportar ZIP: {e}") + + with colB: + if st.button("Exportar Excel (.xlsx)", type="primary"): + try: + xlsx_bytes = export_excel(engine, ambiente) + fname = f"backup_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx" + st.download_button("📥 Baixar Excel", data=xlsx_bytes, file_name=fname, + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + registrar_log(usuario=st.session_state.get("usuario"), + acao=f"Export XLSX (ambiente={ambiente})", + tabela="backup", registro_id=None) + except Exception as e: + st.error(f"Falha ao exportar Excel: {e}") + + with colC: + # Snapshot físico apenas para SQLite + url = str(engine.url) + if url.startswith("sqlite:///"): + if st.button("Snapshot físico (SQLite)", type="secondary"): + try: + snap_bytes = snapshot_sqlite(engine, ambiente) + fname = f"snapshot_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db" + st.download_button("📥 Baixar Snapshot (.db)", data=snap_bytes, file_name=fname, mime="application/octet-stream") + except Exception as e: + st.error(f"Falha ao criar snapshot: {e}") + else: + st.caption("ℹ️ Snapshot físico disponível apenas para SQLite.") + + st.divider() + st.subheader("⬆️ Importar dados") + + mode = st.radio("Modo de importação:", ["APPEND (adicionar)", "REPLACE (substituir tudo)"], horizontal=True) + mode_val = "append" if "APPEND" in mode else "replace" + + up_col1, up_col2 = st.columns(2) + with up_col1: + zip_file = st.file_uploader("Upload ZIP (CSV por tabela)", type=["zip"]) + if zip_file is not None and st.button("Importar do ZIP", type="primary"): + try: + report = import_zip(engine, zip_file.read(), mode=mode_val) + st.success(f"Import ZIP concluído ({mode_val}).") + st.json(report) + registrar_log(usuario=st.session_state.get("usuario"), + acao=f"Import ZIP ({mode_val}, ambiente={ambiente})", + tabela="restore", registro_id=None) + except Exception as e: + st.error(f"Falha ao importar ZIP: {e}") + + with up_col2: + xls_file = st.file_uploader("Upload Excel (.xlsx)", type=["xlsx"]) + if xls_file is not None and st.button("Importar do Excel", type="primary"): + try: + report = import_excel(engine, xls_file.read(), mode=mode_val) + st.success(f"Import Excel concluído ({mode_val}).") + st.json(report) + registrar_log(usuario=st.session_state.get("usuario"), + acao=f"Import XLSX ({mode_val}, ambiente={ambiente})", + tabela="restore", registro_id=None) + except Exception as e: + st.error(f"Falha ao importar Excel: {e}") + + st.divider() + st.info("⚠️ Recomendações:\n" + "• Para restore completo com integridade referencial, prefira snapshot físico no SQLite, ou migrações controladas em bancos como Postgres/SQL Server.\n" + "• O modo REPLACE desabilita FK temporariamente no SQLite para permitir limpeza; use com cautela.\n" + "• Em produção, considere gerar backups com versionamento e retenção (ex.: timestamp no nome do arquivo).") + + +def render(): + # compatível com seu roteador/menu + main() + + +if __name__ == "__main__": + st.set_page_config(page_title="Backup & Restore | ARM", layout="wide") + main() + diff --git a/db_monitor.py b/db_monitor.py new file mode 100644 index 0000000000000000000000000000000000000000..0e10a80dbba830d79b6d5d7b9417ad65529be81c --- /dev/null +++ b/db_monitor.py @@ -0,0 +1,278 @@ + +# db_monitor.py +import streamlit as st +import os +import shutil +import time +from datetime import datetime, timedelta +from sqlalchemy import text +# ✅ Use sempre o engine do BANCO ATIVO (em vez de um engine fixo) +from banco import get_engine, SessionLocal, db_info +from utils_permissoes import verificar_permissao +from utils_auditoria import registrar_log + +# =============================== +# MONITOR & BACKUP DO BANCO +# =============================== +# Objetivo: +# - Mostrar estatísticas do banco (tamanho, páginas, espaço em disco) +# - Definir limiar/capacidade alvo e exibir ocupação (%) +# - Planejar backup (frequência em dias) e retenção (N arquivos) +# - Executar backup e limpar antigos com confirmação +# - Acesso restrito por perfil admin +# +# Observações: +# - Em SQLite: usa PRAGMA page_count/page_size + arquivo .db +# - Em outros dialetos: exibe dialeto e recomenda backup externo +# - Pasta padrão de backup: ./backups (pode alterar na UI) +# - Auditoria: registrar_log(usuario, acao="BACKUP/CLEAN/MONITOR", tabela="schema") + +# (Opcional) rótulo amigável do ambiente atual (Produção/Teste/Treinamento) +try: + from db_router import current_db_choice, bank_label + _HAS_ROUTER = True +except Exception: + _HAS_ROUTER = False + def current_db_choice() -> str: + return "prod" + def bank_label(choice: str) -> str: + return {"prod": "Banco 1 (Produção)", "test": "Banco 2 (Teste)", "treinamento": "Banco 3 (Treinamento)"}\ + .get(choice, choice) + +# ------------------------- +# Auxiliares de dialeto +# ------------------------- +def _engine(): + """Retorna o engine do banco ATIVO (de acordo com a escolha no login).""" + return get_engine() + +def _dialeto(): + try: + return _engine().url.get_backend_name() + except Exception: + return "desconhecido" + +def _sqlite_version(): + if _dialeto() != "sqlite": + return None + try: + with _engine().begin() as conn: + return conn.execute(text("select sqlite_version()")).scalar() + except Exception: + return None + +# ------------------------- +# Info do banco +# ------------------------- +def _db_file_path(): + # Para SQLite, engine.url.database aponta para o arquivo .db + try: + eng = _engine() + return eng.url.database if eng.url.get_backend_name() == "sqlite" else None + except Exception: + return None + +def _sqlite_stats(): + # Retorna dict com stats do SQLite + db_path = _db_file_path() + if not db_path or not os.path.exists(db_path): + return None + + size_bytes = os.path.getsize(db_path) + dir_path = os.path.dirname(os.path.abspath(db_path)) or "." + total, used, free = shutil.disk_usage(dir_path) + + with _engine().begin() as conn: + page_count = conn.execute(text("PRAGMA page_count")).scalar() + page_size = conn.execute(text("PRAGMA page_size")).scalar() + + return { + "db_path": db_path, + "size_bytes": size_bytes, + "page_count": page_count, + "page_size": page_size, + "calc_bytes": (page_count or 0) * (page_size or 0), + "disk_total": total, + "disk_free": free, + "disk_used": used, + "sqlite_version": _sqlite_version(), + } + +# ------------------------- +# Backup +# ------------------------- +def _ensure_dir(path: str): + os.makedirs(path, exist_ok=True) + +def _fmt_bytes(b: int) -> str: + # Formata bytes em unidades legíveis + for unit in ["B","KB","MB","GB","TB"]: + if b < 1024.0: + return f"{b:,.2f} {unit}".replace(",", ".") + b /= 1024.0 + return f"{b:,.2f} PB".replace(",", ".") + +def _listar_backups(backup_dir: str, base_name: str): + """Lista backups para o banco atual. Formato: base_name-YYYYMMDD-HHMMSS.db (ou .zip futuramente)""" + if not os.path.isdir(backup_dir): + return [] + files = [] + for f in os.listdir(backup_dir): + if f.startswith(base_name + "-") and f.endswith(".db"): + full = os.path.join(backup_dir, f) + files.append((f, full, os.path.getmtime(full))) + return sorted(files, key=lambda x: x[2], reverse=True) # ordem decrescente + +def _executar_backup(backup_dir: str): + """Copia o .db para backups/ com timestamp. Registra auditoria.""" + db_path = _db_file_path() + if not db_path or not os.path.exists(db_path): + st.error("Arquivo de banco SQLite não encontrado.") + return False + + _ensure_dir(backup_dir) + base_name = os.path.splitext(os.path.basename(db_path))[0] + stamp = datetime.now().strftime("%Y%m%d-%H%M%S") + dest = os.path.join(backup_dir, f"{base_name}-{stamp}.db") + + try: + shutil.copyfile(db_path, dest) + registrar_log(st.session_state.get("usuario"), f"BACKUP criado: {os.path.basename(dest)}", "schema", None) + st.success(f"✅ Backup criado: {dest}") + return True + except Exception as e: + st.error(f"Erro ao criar backup: {e}") + return False + +def _limpar_antigos(backup_dir: str, base_name: str, manter: int): + """Remove backups antigos, mantendo N mais recentes. Registra auditoria.""" + lst = _listar_backups(backup_dir, base_name) + if len(lst) <= manter: + st.info("Nada para remover: já dentro da retenção.") + return 0 + remover = lst[manter:] + count = 0 + for _, full, _ in remover: + try: + os.remove(full) + count += 1 + except Exception as e: + st.error(f"Erro ao remover {full}: {e}") + if count > 0: + registrar_log(st.session_state.get("usuario"), f"CLEAN backups antigos: {count} removidos (retain={manter})", "schema", None) + st.success(f"🧹 {count} backup(s) antigo(s) removido(s).") + return count + +# ------------------------- +# UI principal +# ------------------------- +def main(): + st.title("🗄️ Monitor e Backup do Banco") + + # 🔐 Proteção: apenas admin + if st.session_state.get("perfil") != "admin": + st.error("⛔ Acesso restrito ao administrador.") + return + + # Badge/URL do banco ativo (opcional) + try: + amb = current_db_choice() + st.caption(f"🧭 Ambiente: {bank_label(amb)}") + except Exception: + pass + try: + info = db_info() + st.caption(f"🔗 Engine URL: {info.get('url')}") + except Exception: + pass + + dial = _dialeto() + st.caption(f"Dialeto do banco: **{dial}**") + + # Estatísticas + stats = _sqlite_stats() if dial == "sqlite" else None + + # Se não for SQLite, exibe recomendações + if dial != "sqlite": + st.info("Este monitor está otimizado para SQLite. Para PostgreSQL/MySQL, configure backup via ferramenta da plataforma (pg_dump/mysqldump) e agendamento externo.") + st.stop() + + if not stats: + st.error("Banco SQLite não encontrado ou inacessível. Verifique o arquivo do banco ativo.") + return + + # Painel de estatísticas + st.subheader("📊 Estatísticas") + colA, colB, colC = st.columns(3) + with colA: + st.metric("Arquivo", os.path.basename(stats["db_path"])) + st.metric("Tamanho do banco (arquivo)", _fmt_bytes(stats["size_bytes"])) + with colB: + st.metric("Páginas (PRAGMA)", f'{stats["page_count"]} × {stats["page_size"]} B') + st.metric("Cálculo (page_count×page_size)", _fmt_bytes(stats["calc_bytes"])) + with colC: + st.metric("Espaço livre no disco", _fmt_bytes(stats["disk_free"])) + st.metric("SQLite version", stats["sqlite_version"] or "—") + + st.divider() + + # Capacidade alvo e ocupação + st.subheader("🎯 Capacidade & Ocupação") + capacidade_gb = st.number_input("Capacidade alvo (GB) — alerta quando ultrapassar", min_value=0.1, value=1.0, step=0.1) + ocupacao_perc = min(100.0, (stats["size_bytes"] / (capacidade_gb * 1024**3)) * 100.0) if capacidade_gb > 0 else 0.0 + + st.progress(min(1.0, ocupacao_perc / 100.0)) + st.caption(f"Ocupação estimada: **{ocupacao_perc:,.2f}%** de {capacidade_gb} GB") + + if ocupacao_perc >= 80.0: + st.warning("⚠️ Ocupação acima de 80%. Considere backup/arquivamento.") + + st.divider() + + # Planejamento de backup + st.subheader("🗓️ Planejamento de Backup") + backup_dir = st.text_input("Pasta de backups", value="backups") + _ensure_dir(backup_dir) # garante a pasta + base_name = os.path.splitext(os.path.basename(stats["db_path"]))[0] + backups = _listar_backups(backup_dir, base_name) + + # Último e próximo + ultimo_backup_dt = datetime.fromtimestamp(backups[0][2]) if backups else None + freq_dias = st.number_input("Frequência (dias)", min_value=1, value=7) + retencao = st.number_input("Retenção máx. de backups (arquivos)", min_value=1, value=10) + proximo_backup_dt = (ultimo_backup_dt + timedelta(days=freq_dias)) if ultimo_backup_dt else (datetime.now() + timedelta(days=freq_dias)) + + col1, col2, col3 = st.columns(3) + with col1: + st.metric("Último backup", ultimo_backup_dt.strftime("%d/%m/%Y %H:%M:%S") if ultimo_backup_dt else "—") + with col2: + st.metric("Próximo previsto", proximo_backup_dt.strftime("%d/%m/%Y %H:%M:%S")) + with col3: + st.metric("Backups atuais", len(backups)) + + # Aviso se vencido + if ultimo_backup_dt and datetime.now() >= proximo_backup_dt: + st.warning("⏰ Backup previsto já venceu. Execute agora para manter o plano.") + + # Ações + st.subheader("⚙️ Ações") + colX, colY, colZ = st.columns(3) + with colX: + if st.button("💾 Backup agora"): + if _executar_backup(backup_dir): + st.rerun() + with colY: + if st.button("🧹 Limpar antigos (manter retenção)"): + _limpar_antigos(backup_dir, base_name, int(retencao)) + st.rerun() + with colZ: + # Apenas mostra lista dos últimos backups + if backups: + st.write("Últimos backups:") + for f, full, mtime in backups[:5]: + dt = datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S") + st.caption(f"• {f} ({dt})") + + # Auditoria de visualização (opcional) + registrar_log(st.session_state.get("usuario"), "MONITOR DB", "schema", None) + diff --git a/db_router.py b/db_router.py new file mode 100644 index 0000000000000000000000000000000000000000..81a06ddc4eeb64bf7463727ca1769a57b850a655 --- /dev/null +++ b/db_router.py @@ -0,0 +1,152 @@ + +# -*- coding: utf-8 -*- +""" +db_router.py — Roteia Engine/SessionLocal para 'prod' (Load.db), 'test' (Load_teste.db) +e 'treinamento' (Load_treinamento.db), conforme escolha do usuário (mantida em st.session_state). + +• set_db_choice("prod"|"test"|"treinamento") → define o banco ativo para a sessão do usuário +• current_db_choice() → retorna 'prod' | 'test' | 'treinamento' +• get_engine() → engine do banco ativo (SQLite) +• get_session_factory() → sessionmaker do banco ativo +• SessionLocal() → sessão pronta no banco ativo + +Observação: +- Este arquivo monta os caminhos dos bancos SQLite com base na pasta do projeto + (onde está este arquivo), sem depender de .env. +- Se quiser usar .env futuramente, há uma seção comentada para isso. +""" + +import os +import streamlit as st +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +# ============================ +# Caminhos dos bancos (SQLite) +# ============================ +# Pasta base do projeto (onde está este db_router.py) +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +# Nomes dos arquivos de banco conforme sua especificação +PROD_DB_NAME = "Load.db" +TEST_DB_NAME = "Load_teste.db" +TREINAMENTO_DB_NAME = "Load_treinamento.db" + +# Monta URLs SQLite (formato: sqlite:///C:/.../arquivo.db) +DB1_PROD_URL = f"sqlite:///{os.path.join(BASE_DIR, PROD_DB_NAME)}" +DB2_TEST_URL = f"sqlite:///{os.path.join(BASE_DIR, TEST_DB_NAME)}" +DB3_TREINAMENTO_URL = f"sqlite:///{os.path.join(BASE_DIR, TREINAMENTO_DB_NAME)}" + +# ============================ +# (Opcional) Uso de .env +# ============================ +# Se preferir usar .env futuramente, descomente abaixo e defina: +# DB1_PROD_URL=sqlite:///C:/.../Load.db +# DB2_TEST_URL=sqlite:///C:/.../Load_teste.db +# DB3_TREINAMENTO_URL=sqlite:///C:/.../Load_treinamento.db +# +# from dotenv import load_dotenv +# load_dotenv() +# DB1_PROD_URL = os.getenv("DB1_PROD_URL", DB1_PROD_URL) +# DB2_TEST_URL = os.getenv("DB2_TEST_URL", DB2_TEST_URL) +# DB3_TREINAMENTO_URL = os.getenv("DB3_TREINAMENTO_URL", DB3_TREINAMENTO_URL) + +# ============================ +# Catálogo (helpers de UI — opcional) +# ============================ +DB_URLS = { + "prod": DB1_PROD_URL, + "test": DB2_TEST_URL, + "treinamento": DB3_TREINAMENTO_URL, +} + +DB_LABELS = { + "prod": "Banco 1 (Produção)", + "test": "Banco 2 (Teste)", + "treinamento": "Banco 3 (Treinamento)", +} + +def list_banks() -> list[str]: + """Lista as chaves de bancos disponíveis (opcional para UI).""" + return list(DB_URLS.keys()) + +def bank_label(choice: str) -> str: + """Rótulo amigável para a UI (opcional).""" + return DB_LABELS.get(choice, choice) + +# ============================ +# Chaves de sessão +# ============================ +SESSION_DB_CHOICE_KEY = "__db_choice__" # "prod" | "test" | "treinamento" +SESSION_DB_ENGINE_KEY = "__db_engine__" # cache de engine por escolha +SESSION_DB_FACTORY_KEY = "__db_session_factory__" # cache de sessionmaker por escolha + +def set_db_choice(choice: str): + """ + Define o banco ativo para a sessão do usuário. + choice ∈ {"prod", "test", "treinamento"}. + """ + choice = (choice or "").strip().lower() + if choice not in DB_URLS: + raise ValueError(f"db_choice inválido. Use uma destas chaves: {list(DB_URLS.keys())}") + st.session_state[SESSION_DB_CHOICE_KEY] = choice + # Ao trocar banco, invalida caches locais + st.session_state.pop(SESSION_DB_ENGINE_KEY, None) + st.session_state.pop(SESSION_DB_FACTORY_KEY, None) + +def current_db_choice() -> str: + """Retorna 'prod' | 'test' | 'treinamento'. Default: 'prod'.""" + return st.session_state.get(SESSION_DB_CHOICE_KEY, "prod") + +def _url_for_choice(choice: str) -> str: + return DB_URLS[choice] + +def get_engine(): + """ + Entrega o engine do banco ATIVO (por sessão). Cria se não existir. + """ + choice = current_db_choice() + cached = st.session_state.get(SESSION_DB_ENGINE_KEY) + if cached and getattr(cached, "__db_choice__", None) == choice: + return cached + + url = _url_for_choice(choice) + engine_args = { + "echo": False, + "pool_pre_ping": True, + # check_same_thread=False para permitir uso em múltiplas threads do Streamlit + "connect_args": {"check_same_thread": False} if url.startswith("sqlite") else {}, + } + + eng = create_engine(url, **engine_args) + setattr(eng, "__db_choice__", choice) + st.session_state[SESSION_DB_ENGINE_KEY] = eng + return eng + +def get_session_factory(): + """ + Entrega um sessionmaker vinculado ao engine do banco ATIVO. + """ + choice = current_db_choice() + fac = st.session_state.get(SESSION_DB_FACTORY_KEY) + if fac and getattr(fac, "__db_choice__", None) == choice: + return fac + + fac = sessionmaker(bind=get_engine(), autocommit=False, autoflush=False) + setattr(fac, "__db_choice__", choice) + st.session_state[SESSION_DB_FACTORY_KEY] = fac + return fac + +def SessionLocal(): + """ + Cria uma sessão (sempre no banco ativo). + Uso: + db = SessionLocal() + try: + ... + finally: + db.close() + """ + return get_session_factory()() + + diff --git a/db_tools.py b/db_tools.py new file mode 100644 index 0000000000000000000000000000000000000000..04d73ccfdcecde509026820e6502f434e6e51805 --- /dev/null +++ b/db_tools.py @@ -0,0 +1,65 @@ + +# db_tools.py +import streamlit as st +from banco import engine +from sqlalchemy import text + +# Ajuste a sintaxe conforme seu SGDB. Abaixo está para bancos comuns (Postgres, SQLite, MySQL*). +# *MySQL funciona, mas o CREATE UNIQUE INDEX IF NOT EXISTS pode variar por versão. Ver obs. abaixo. +SQLS = [ + "ALTER TABLE usuarios ADD COLUMN nome VARCHAR(255);", + "ALTER TABLE usuarios ADD COLUMN email VARCHAR(255);", + "CREATE UNIQUE INDEX IF NOT EXISTS ix_usuarios_email ON usuarios (email);" +] + +def aplicar_alteracoes(): + with engine.begin() as conn: + for sql in SQLS: + conn.execute(text(sql)) + +def verificar_colunas(): + # Tenta listar colunas da tabela usuarios (funciona na maioria dos drivers) + try: + with engine.begin() as conn: + result = conn.execute(text("PRAGMA table_info(usuarios)")) # SQLite + cols = [row[1] for row in result.fetchall()] + return cols + except: + # Fallback genérico usando INFORMATION_SCHEMA (Postgres/MySQL) + try: + with engine.begin() as conn: + result = conn.execute(text(""" + SELECT column_name + FROM information_schema.columns + WHERE table_name = 'usuarios' + ORDER BY ordinal_position; + """)) + cols = [row[0] for row in result.fetchall()] + return cols + except: + return None + +def main(): + st.title("🛠️ Ferramentas de Banco") + + if st.session_state.get("perfil") != "admin": + st.error("❌ Acesso restrito ao administrador.") + return + + st.info("Este utilitário adiciona as colunas **nome** e **email** na tabela **usuarios** e cria o índice único do **email**.") + + cols = verificar_colunas() + if cols: + st.write("📋 Colunas atuais em `usuarios`:", ", ".join(cols)) + else: + st.warning("Não foi possível listar colunas automaticamente. Ainda é possível aplicar as alterações.") + + if st.button("✅ Aplicar alterações (nome/email + índice)"): + try: + aplicar_alteracoes() + st.success("Alterações aplicadas com sucesso! Reinicie a aplicação se necessário.") + except Exception as e: + st.error(f"Erro ao aplicar alterações: {e}") + st.stop() + + st.caption("Dica: após aplicar, confira o cadastro/edição de usuários e o login para ver email/nome funcionando.") diff --git a/env_audit.py b/env_audit.py new file mode 100644 index 0000000000000000000000000000000000000000..cc649f044580c2860057e5cebe5d2fe1cc2fb51b --- /dev/null +++ b/env_audit.py @@ -0,0 +1,321 @@ + +# -*- coding: utf-8 -*- +""" +env_audit.py — Auditoria de ambiente (TESTE/Produção) para projetos Streamlit +Autor: Rodrigo / ARM | 2026 + +Verifica: +• Uso de st.set_page_config fora de if __name__ == "__main__" +• Presença de funções main()/render() em módulos +• Diferenças entre arquivos .py e modules_map.py (sugestão de "file") +• Heurística de banco (banco.py apontando para produção) +• Gera env_audit_report.json + imprime resumo Markdown + +Uso: + python env_audit.py --env "C:\\...\\ambiente_teste\\LoadApp" +(ou) comparar pastas: + python env_audit.py --prod "C:\\...\\producao\\LoadApp" --test "C:\\...\\ambiente_teste\\LoadApp" +""" + +import os, sys, json, ast, re +from typing import Dict, List, Tuple, Optional + +def list_py(base: str) -> List[str]: + files = [] + for root, _, names in os.walk(base): + for n in names: + if n.endswith(".py"): + files.append(os.path.normpath(os.path.join(root, n))) + return files + +def relpath_set(base: str) -> set: + return set([os.path.relpath(p, base) for p in list_py(base)]) + +def parse_ast(path: str) -> Optional[ast.AST]: + try: + with open(path, "r", encoding="utf-8") as f: + src = f.read() + return ast.parse(src, filename=path) + except Exception: + return None + +def has_set_page_config_outside_main(tree: ast.AST) -> bool: + """ + Retorna True se encontrar chamada a set_page_config fora do guard if __name__ == "__main__". + Heurística: procura ast.Call para nome/atributo 'set_page_config' e confere se está dentro de um If guard. + """ + if tree is None: + return False + + calls = [] + parents = {} + + class ParentAnnotator(ast.NodeVisitor): + def generic_visit(self, node): + for child in ast.iter_child_nodes(node): + parents[child] = node + super().generic_visit(node) + + class CallCollector(ast.NodeVisitor): + def visit_Call(self, node): + # detecta set_page_config + name = None + if isinstance(node.func, ast.Name): + name = node.func.id + elif isinstance(node.func, ast.Attribute): + name = node.func.attr + if name == "set_page_config": + calls.append(node) + self.generic_visit(node) + + ParentAnnotator().visit(tree) + CallCollector().visit(tree) + + def inside_main_guard(node: ast.AST) -> bool: + # sobe na árvore: se estiver dentro de um If com __name__ == "__main__" + cur = node + while cur in parents: + cur = parents[cur] + if isinstance(cur, ast.If): + # test é __name__ == "__main__" + t = cur.test + # match Name('__name__') == Constant('__main__') + if isinstance(t, ast.Compare): + left = t.left + comparators = t.comparators + if isinstance(left, ast.Name) and left.id == "__name__" and comparators: + comp = comparators[0] + if isinstance(comp, ast.Constant) and comp.value == "__main__": + return True + return False + + # Se houver set_page_config e nenhuma estiver dentro do guard, marca True (incorreto) + for c in calls: + if not inside_main_guard(c): + return True + return False + +def has_function(tree: ast.AST, fn_name: str) -> bool: + if tree is None: + return False + for node in ast.walk(tree): + if isinstance(node, ast.FunctionDef) and node.name == fn_name: + return True + return False + +def guess_module_name(file_path: str, base: str) -> str: + """Retorna o nome do módulo sem extensão e sem subdiretórios ('modulos/x.py' -> 'x').""" + rel = os.path.relpath(file_path, base) + name = os.path.splitext(os.path.basename(rel))[0] + return name + +def read_modules_map(path: str) -> Dict[str, Dict]: + """ + Lê modules_map.py e tenta extrair o dict MODULES. + Método simples: regex para entries de primeiro nível. + """ + modmap = {} + mm_path = os.path.join(path, "modules_map.py") + if not os.path.isfile(mm_path): + return modmap + try: + with open(mm_path, "r", encoding="utf-8") as f: + src = f.read() + # encontra blocos de entries "key": { ... } + # Simplificação: captura 'MODULES = { ... }' então entradas por aspas + chave. + main_match = re.search(r"MODULES\s*=\s*\{(.+?)\}\s*$", src, flags=re.S) + if not main_match: + return modmap + body = main_match.group(1) + # Captura nomes de entradas: "nome": { ... } + entry_re = re.compile(r'"\'["\']\s*:\s*\{(.*?)\}', re.S) + for em in entry_re.finditer(body): + key = em.group(1) + obj = em.group(2) + # captura "file": "xyz" se houver + file_m = re.search(r'["\']file["\']\s*:\s*"\'["\']', obj) + label_m = re.search(r'["\']label["\']\s*:\s*"\'["\']', obj) + perfis_m = re.findall(r'["\']perfis["\']\s*:\s*\[([^\]]+)\]', obj) + grupo_m = re.search(r'["\']grupo["\']\s*:\s*"\'["\']', obj) + modmap[key] = { + "file": file_m.group(1) if file_m else None, + "label": label_m.group(1) if label_m else key, + "perfis": perfis_m[0] if perfis_m else None, + "grupo": grupo_m.group(1) if grupo_m else None, + } + except Exception: + pass + return modmap + +def audit_env(env_path: str, prod_path: Optional[str] = None) -> Dict: + report = { + "env_path": env_path, + "prod_path": prod_path, + "files_total": 0, + "issues": { + "set_page_config_outside_main": [], # lista de arquivos + "missing_entry_points": [], # (arquivo, has_main, has_render) + "modules_map_mismatches": [], # (key, file, suggestion) + "db_prod_risk": [], # banco.py hints + "missing_in_test": [], # se prod_path fornecido + "extra_in_test": [], # se prod_path fornecido + }, + "summary": {} + } + + env_files = list_py(env_path) + report["files_total"] = len(env_files) + + # modules_map + modmap = read_modules_map(env_path) + + # varredura de .py + for f in env_files: + tree = parse_ast(f) + if has_set_page_config_outside_main(tree): + report["issues"]["set_page_config_outside_main"].append(os.path.relpath(f, env_path)) + + has_main = has_function(tree, "main") + has_render = has_function(tree, "render") + if not (has_main or has_render): + report["issues"]["missing_entry_points"].append((os.path.relpath(f, env_path), has_main, has_render)) + + # banco.py heuristic + banco_path = os.path.join(env_path, "banco.py") + if os.path.isfile(banco_path): + try: + with open(banco_path, "r", encoding="utf-8") as bf: + bsrc = bf.read().lower() + # heurísticas simples (ajuste conforme seu cenário) + hints = [] + if "prod" in bsrc or "production" in bsrc: + hints.append("contém 'prod'/'production' no banco.py (pode estar apontando para produção)") + if "localhost" in bsrc or "127.0.0.1" in bsrc: + hints.append("contém localhost (ok se seu DB de teste for local)") + if "ambiente_teste" in bsrc or "test" in bsrc: + hints.append("contém 'test'/'ambiente_teste' (bom indício de DB de teste)") + if hints: + report["issues"]["db_prod_risk"].extend(hints) + except Exception: + pass + + # Mismatch entre modules_map e arquivos + # Constrói um conjunto de nomes de módulo possíveis (arquivo base sem .py) + module_name_set = set([guess_module_name(f, env_path) for f in env_files]) + for key, info in modmap.items(): + file_hint = info.get("file") + target = file_hint or key + if target not in module_name_set: + # sugere arquivo com nome mais próximo + suggestions = [m for m in module_name_set if m.lower() == key.lower()] + sug = suggestions[0] if suggestions else None + report["issues"]["modules_map_mismatches"].append((key, file_hint, sug)) + + # Diferenças entre PRODUÇÃO e TESTE (se prod_path fornecido) + if prod_path and os.path.isdir(prod_path): + prod_set = relpath_set(prod_path) + test_set = relpath_set(env_path) + missing = sorted(list(prod_set - test_set)) + extra = sorted(list(test_set - prod_set)) + report["issues"]["missing_in_test"] = missing + report["issues"]["extra_in_test"] = extra + + # resumo + report["summary"] = { + "files_total": report["files_total"], + "set_page_config_outside_main_count": len(report["issues"]["set_page_config_outside_main"]), + "missing_entry_points_count": len(report["issues"]["missing_entry_points"]), + "modules_map_mismatches_count": len(report["issues"]["modules_map_mismatches"]), + "db_risk_flags_count": len(report["issues"]["db_prod_risk"]), + "missing_in_test_count": len(report["issues"]["missing_in_test"]), + "extra_in_test_count": len(report["issues"]["extra_in_test"]), + } + + return report + +def print_markdown(report: Dict): + env_path = report["env_path"] + prod_path = report.get("prod_path") or "—" + print(f"# 🧪 Auditoria de Ambiente\n") + print(f"- **Pasta auditada (TESTE)**: `{env_path}`") + print(f"- **Pasta de PRODUÇÃO (comparação)**: `{prod_path}`\n") + + s = report["summary"] + print("## ✅ Resumo") + for k, v in s.items(): + print(f"- {k.replace('_',' ').title()}: {v}") + print() + + issues = report["issues"] + + if issues["set_page_config_outside_main"]: + print("## ⚠️ `st.set_page_config` fora de `if __name__ == '__main__'`") + for f in issues["set_page_config_outside_main"]: + print(f"- {f}") + print("**Ajuste**: mover `st.set_page_config(...)` para dentro de:\n") + print("```python\nif __name__ == \"__main__\":\n st.set_page_config(...)\n main()\n```\n") + + if issues["missing_entry_points"]: + print("## ⚠️ Módulos sem `main()` e sem `render()`") + for f, has_main, has_render in issues["missing_entry_points"]: + print(f"- {f} — main={has_main}, render={has_render}") + print("**Ajuste**: garantir pelo menos uma função de entrada (`main` ou `render`).\n") + + if issues["modules_map_mismatches"]: + print("## ⚠️ Diferenças entre `modules_map.py` e arquivos") + for key, file_hint, sug in issues["modules_map_mismatches"]: + print(f"- key=`{key}` | file=`{file_hint}` | sugestão de arquivo=`{sug or 'verificar manualmente'}`") + print("**Ajuste**: em `modules_map.py`, defina `\"file\": \"nome_do_arquivo\"` quando o nome do arquivo não for igual à key.\n") + + if issues["db_prod_risk"]: + print("## ⚠️ Banco (heurística)") + for m in issues["db_prod_risk"]: + print(f"- {m}") + print("**Ajuste**: confirmar que `banco.py` do TESTE aponta para DB de teste (não produção).\n") + + if issues["missing_in_test"]: + print("## 🟠 Arquivos que existem na PRODUÇÃO e faltam no TESTE") + for f in issues["missing_in_test"]: + print(f"- {f}") + print() + + if issues["extra_in_test"]: + print("## 🔵 Arquivos que existem no TESTE e não existem na PRODUÇÃO") + for f in issues["extra_in_test"]: + print(f"- {f}") + print() + +def main(): + import argparse + p = argparse.ArgumentParser() + p.add_argument("--env", help="Pasta do ambiente a auditar (ex.: ...\\ambiente_teste\\LoadApp)") + p.add_argument("--prod", help="(Opcional) Pasta de PRODUÇÃO para comparação.") + p.add_argument("--test", help="(Opcional) Pasta de TESTE para comparação (se usar --prod).") + args = p.parse_args() + + if args.env: + env_path = os.path.normpath(args.env) + report = audit_env(env_path) + elif args.prod and args.test: + prod = os.path.normpath(args.prod) + test = os.path.normpath(args.test) + report = audit_env(test, prod_path=prod) + else: + print("Uso: python env_audit.py --env \"C:\\...\\ambiente_teste\\LoadApp\"\n" + " ou: python env_audit.py --prod \"C:\\...\\producao\\LoadApp\" --test \"C:\\...\\ambiente_teste\\LoadApp\"") + sys.exit(2) + + # salva JSON + out_json = os.path.join(os.getcwd(), "env_audit_report.json") + try: + with open(out_json, "w", encoding="utf-8") as f: + json.dump(report, f, ensure_ascii=False, indent=2) + print(f"\n💾 Relatório salvo em: {out_json}\n") + except Exception as e: + print(f"Falha ao salvar JSON: {e}") + + # imprime resumo markdown + print_markdown(report) + +if __name__ == "__main__": + main() diff --git a/fix_schema.py b/fix_schema.py new file mode 100644 index 0000000000000000000000000000000000000000..884b7e7f3036a1feddab16a5ce69e9a16918fa1d --- /dev/null +++ b/fix_schema.py @@ -0,0 +1,34 @@ + +# fix_schema.py +# -*- coding: utf-8 -*- +""" +Utilitário rápido para ajustar o schema da tabela 'usuarios' em SQLite: +- Verifica se a coluna 'data_aniversario' existe. +- Se não existir, cria com tipo DATE (compatível com datetime.date via SQLAlchemy). +⚠️ Execute isso no banco ATIVO (Produção/Teste), conforme seu db_router. +""" + +from sqlalchemy import text +from banco import engine # usa o mesmo engine do app + +def column_exists(conn, table: str, column: str) -> bool: + # SQLite: PRAGMA para listar colunas + res = conn.execute(text(f"PRAGMA table_info({table});")).fetchall() + cols = [row[1] for row in res] # row[1] = nome da coluna + return column in cols + +def add_date_column(conn, table: str, column: str): + # SQLite aceita ALTER TABLE ADD COLUMN simples + conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} DATE;")) + +def main(): + with engine.begin() as conn: + if column_exists(conn, "usuarios", "data_aniversario"): + print("✅ Coluna 'data_aniversario' já existe em 'usuarios'.") + else: + print("➕ Criando coluna 'data_aniversario' (DATE) em 'usuarios'...") + add_date_column(conn, "usuarios", "data_aniversario") + print("✅ Coluna criada com sucesso.") + +if __name__ == "__main__": + main() diff --git a/form_equipamento.py b/form_equipamento.py new file mode 100644 index 0000000000000000000000000000000000000000..7c8d9023ba804d65ca9a16e922bd17df0b99ae18 --- /dev/null +++ b/form_equipamento.py @@ -0,0 +1,98 @@ +import streamlit as st +from utils_fpso import campo_fpso + +MODAL_LISTA = ["", "AÉREO", "MARÍTIMO", "EXPRESSO"] +RESP_ERRO_LISTA = ["", "Sim", "Não"] +INCLUSAO_EXCLUSAO_LISTA = ["", "INCLUSÃO", "EXCLUSÃO"] + + +def form_equipamento(registro): + st.markdown("### 📦 Dados do Equipamento") + + col1, col2, col3 = st.columns(3) + + # ===================== + # COLUNA 1 + # ===================== + with col1: + fpso1 = campo_fpso("FPSO1", registro.fpso1) + fpso = campo_fpso("FPSO", registro.fpso) + data_coleta = st.date_input("Data Coleta", registro.data_coleta) + especialista = st.text_input("Especialista", registro.especialista or "") + conferente = st.text_input("Conferente", registro.conferente or "") + osm = st.text_input("OSM", registro.osm or "") + + # ===================== + # COLUNA 2 + # ===================== + with col2: + modal = st.selectbox( + "Modal", + MODAL_LISTA, + index=MODAL_LISTA.index(registro.modal) if registro.modal in MODAL_LISTA else 0 + ) + + quant_equip = st.number_input( + "Quantidade Equipamentos", + min_value=0, + value=registro.quant_equip or 0 + ) + + mrob = st.text_input("MROB", registro.mrob or "") + linhas_osm = st.number_input("Linhas OSM", value=registro.linhas_osm or 0) + linhas_mrob = st.number_input("Linhas MROB", value=registro.linhas_mrob or 0) + linhas_erros = st.number_input("Linhas com Erro", value=registro.linhas_erros or 0) + + # ===================== + # COLUNA 3 + # ===================== + with col3: + erro_storekeeper = st.selectbox( + "Erro Storekeeper", + RESP_ERRO_LISTA, + index=RESP_ERRO_LISTA.index(registro.erro_storekeeper) + if registro.erro_storekeeper in RESP_ERRO_LISTA else 0 + ) + + erro_operacao = st.selectbox( + "Erro Operação WH", + RESP_ERRO_LISTA, + index=RESP_ERRO_LISTA.index(registro.erro_operacao) + if registro.erro_operacao in RESP_ERRO_LISTA else 0 + ) + + erro_especialista = st.selectbox( + "Erro Especialista WH", + RESP_ERRO_LISTA, + index=RESP_ERRO_LISTA.index(registro.erro_especialista) + if registro.erro_especialista in RESP_ERRO_LISTA else 0 + ) + + erro_outros = st.selectbox( + "Erro Outros", + RESP_ERRO_LISTA, + index=RESP_ERRO_LISTA.index(registro.erro_outros) + if registro.erro_outros in RESP_ERRO_LISTA else 0 + ) + + inclusao_exclusao = st.selectbox( + "Inclusão / Exclusão", + INCLUSAO_EXCLUSAO_LISTA, + index=INCLUSAO_EXCLUSAO_LISTA.index(registro.inclusao_exclusao) + if registro.inclusao_exclusao in INCLUSAO_EXCLUSAO_LISTA else 0 + ) + + # ===================== + # CAMPOS GERAIS + # ===================== + st.markdown("### 📝 Informações Complementares") + + solicitante = st.text_input("Solicitante", registro.solicitante or "") + ivo = st.text_input("Motivo Inclusão / Exclusão", registro.ivo or "") + requisitante = st.text_input("Requisitante", registro.requisitante or "") + nota_fiscal = st.text_input("Nota Fiscal", registro.nota_fiscal or "") + impacto = st.text_input("Impacto", registro.impacto or "") + dimensao = st.text_input("Dimensão", registro.dimensao or "") + observacoes = st.text_area("Observações", registro.observacoes or "", height=120) + + return locals() diff --git a/formulario.py b/formulario.py new file mode 100644 index 0000000000000000000000000000000000000000..d750919421984f554640f45a89bcdedd2b363ee6 --- /dev/null +++ b/formulario.py @@ -0,0 +1,519 @@ + +# -*- coding: utf-8 -*- +import streamlit as st +from datetime import datetime, date +from banco import SessionLocal +from models import Equipamento +from sqlalchemy import distinct +from log import registrar_log + +# ====================================================== +# LISTAS FIXAS +# ====================================================== +MODAL_LISTA = ["", "AÉREO", "MARÍTIMO", "EXPRESSO"] +INCLUSAO_EXCLUSAO_LISTA = ["", "INCLUSÃO", "EXCLUSÃO"] +RESP_ERRO_LISTA = ["", "Sim", "Não"] +D_LISTA = ["", "D1", "D2", "D3"] + +# ====================================================== +# LAYOUT +# ====================================================== +def layout_padrao(titulo: str): + st.title(titulo) + st.divider() + +# ====================================================== +# UTILS +# ====================================================== +def _safe_index(options, value, default=0): + try: + return options.index(value) + except Exception: + return default + +def _ss_get(key, default=None): + return st.session_state.get(key, default) + +def _ss_set(key, value): + st.session_state[key] = value + +# 🟢 Parser de datas robusto (aceita date, datetime e string) +def _parse_date_any(v): + if isinstance(v, date): + return v + if isinstance(v, datetime): + return v.date() + if isinstance(v, str): + s = v.strip() + # ISO: YYYY-MM-DD + try: + return date.fromisoformat(s) + except Exception: + pass + # BR: DD/MM/YYYY + try: + return datetime.strptime(s, "%d/%m/%Y").date() + except Exception: + pass + # YYYY/MM/DD (alguns DBs/ETLs geram assim) + try: + return datetime.strptime(s, "%Y/%m/%d").date() + except Exception: + pass + return None + +def _build_prefill_from_record(r: Equipamento) -> dict: + # 🟢 Usa parser que converte string->date quando possível + dc = _parse_date_any(getattr(r, "data_coleta", None)) + return { + "fpso1": r.fpso1 or "", + "fpso": r.fpso or "", + "data_coleta": dc, # 🟢 + "especialista": r.especialista or "", + "conferente": r.conferente or "", + "osm": r.osm or "", + "modal": r.modal or "", + "quant_equip": int(r.quant_equip or 0), + "mrob": r.mrob or "", + "linhas_osm": int(r.linhas_osm or 0), + "linhas_mrob": int(r.linhas_mrob or 0), + "linhas_erros": int(r.linhas_erros or 0), + "erro_storekeeper": r.erro_storekeeper or "", + "erro_operacao": r.erro_operacao or "", + "erro_especialista": r.erro_especialista or "", + "erro_outros": r.erro_outros or "", + "inclusao_exclusao": r.inclusao_exclusao or "", + "po": r.po or "", + "part_number": r.part_number or "", + "material": r.material or "", + "solicitante": r.solicitante or "", + "requisitante": r.requisitante or "", + "nota_fiscal": r.nota_fiscal or "", + "impacto": r.impacto or "", + "dimensao": r.dimensao or "", + "motivo": getattr(r, "motivo", "") or "", + "observacoes": r.observacoes or "", + "dia_inclusao": r.dia_inclusao or "", + "_origem_id": r.id, + } + +@st.cache_data +def get_distinct(_campo): + db = SessionLocal() + try: + rows = db.query(distinct(_campo)).filter(_campo.isnot(None)).filter(_campo != "").all() + return sorted([r[0] for r in rows]) + finally: + db.close() + +def get_fpsos(): + return [""] + get_distinct(Equipamento.fpso) + +def get_fpsos1(): + return [""] + get_distinct(Equipamento.fpso1) + +def get_notas_fiscais(): + return sorted([str(x) for x in get_distinct(Equipamento.nota_fiscal)]) + +def _apply_prefill_to_state(prefill: dict, debug=False): + """ + Aplica o prefill uma única vez por origem (ID), antes de criar widgets. + Se o valor não existir nas listas, move para o campo '➕ Novo ...' + text_input. + """ + if not prefill: + if debug: st.info("DEBUG: prefill vazio. Nada a aplicar.") + return + + origem = prefill.get("_origem_id") + if _ss_get("__prefill_applied__") == origem: + if debug: st.info(f"DEBUG: prefill já aplicado para origem {origem}.") + return + + # Carrega todos os campos no session_state como w_* + for k, v in prefill.items(): + _ss_set(f"w_{k}", v) + + # Backups para os inputs de texto dos "➕ Novo ..." + _ss_set("w_fpso1_text", prefill.get("fpso1", "")) + _ss_set("w_fpso_text", prefill.get("fpso", "")) + + # Normalização para selects fixos + def _norm(key, allowed): + v = _ss_get(key, "") + if v not in allowed: + _ss_set(key, allowed[0]) + + _norm("w_modal", MODAL_LISTA) + _norm("w_erro_storekeeper", RESP_ERRO_LISTA) + _norm("w_erro_operacao", RESP_ERRO_LISTA) + _norm("w_erro_especialista", RESP_ERRO_LISTA) + _norm("w_erro_outros", RESP_ERRO_LISTA) + _norm("w_inclusao_exclusao", INCLUSAO_EXCLUSAO_LISTA) + _norm("w_dia_inclusao", D_LISTA) + + # 🟢 Tipos/valores coerentes + _ss_set("w_quant_equip", int(_ss_get("w_quant_equip", 0) or 0)) + _ss_set("w_linhas_osm", int(_ss_get("w_linhas_osm", 0) or 0)) + _ss_set("w_linhas_mrob", int(_ss_get("w_linhas_mrob", 0) or 0)) + _ss_set("w_linhas_erros", int(_ss_get("w_linhas_erros", 0) or 0)) + + # 🟢 NÃO força “hoje” se vier None — mas o widget precisa de um valor (trata na UI) + # _ss_set("w_data_coleta", _ss_get("w_data_coleta") or date.today()) + + _ss_set("__prefill_applied__", origem) + if debug: st.success(f"DEBUG: prefill aplicado. origem={origem}") + +# ====================================================== +# APP +# ====================================================== +def main(): + layout_padrao( + "📋 CONTROLE DE OSM – Inclusões / Exclusões • Visão por linha (D3 a partir das 16h)" + ) + + debug = st.toggle("🔍 Modo debug", value=False) + + # ====================================================== + # EXPANDER • Pré-carregar por Nota Fiscal + # ====================================================== + with st.expander("🧾 Pré-carregar via Nota Fiscal (clonar como nova entrada)", expanded=False): + col1, col2, col3 = st.columns([2, 2, 1.2]) + nf_digitado = col1.text_input("Digite a Nota Fiscal", key="nf_lookup") + nf_select = col2.selectbox("Ou selecione", [""] + get_notas_fiscais(), key="nf_select") + marcar_origem = col3.checkbox("Marcar origem", value=True, key="nf_marcar_origem") + + c1, c2 = st.columns(2) + buscar = c1.button("🔎 Buscar NF", key="btn_buscar_nf") + limpar = c2.button("🧹 Limpar pré-preenchimento", key="btn_limpar_prefill") + + if limpar: + # Limpa apenas os w_* e marcadores de prefill/resultados + for k in list(st.session_state.keys()): + if k.startswith("w_"): + del st.session_state[k] + st.session_state.pop("form_prefill", None) + st.session_state.pop("__prefill_applied__", None) + st.session_state.pop("__nf_busca_result__", None) + st.session_state.pop("__nf_busca_opts__", None) + st.session_state.pop("sel_registro_base", None) + st.success("Pré-preenchimento limpo.") + st.rerun() + + if buscar: + nf = (nf_digitado or nf_select or "").strip() + if not nf: + st.warning("Informe ou selecione uma Nota Fiscal.") + else: + db = SessionLocal() + try: + regs = ( + db.query(Equipamento) + .filter(Equipamento.nota_fiscal == nf) + .order_by(Equipamento.id.desc()) + .all() + ) + except Exception as e: + regs = [] + st.error(f"Erro ao buscar NF: {e}") + finally: + db.close() + + if not regs: + st.info("Nenhum registro encontrado para a NF informada.") + st.session_state.pop("__nf_busca_result__", None) + st.session_state.pop("__nf_busca_opts__", None) + else: + # Persistimos APENAS IDs e um resumo textual (não guardamos objetos SQLAlchemy) + result = [ + {"id": r.id, "data_coleta": str(r.data_coleta), "fpso": r.fpso or "—", "osm": r.osm or "—"} + for r in regs + ] + opts = [f"ID {x['id']} | {x['data_coleta']} | FPSO {x['fpso']} | OSM {x['osm']}" for x in result] + + st.session_state["__nf_busca_result__"] = result + st.session_state["__nf_busca_opts__"] = opts + # Reset da seleção anterior + st.session_state["sel_registro_base"] = opts[0] if opts else None + + # ⬇️ Mostrar seleção + botão USAR sempre que houver resultado persistido + if st.session_state.get("__nf_busca_opts__"): + escolha = st.selectbox( + "Selecione o registro base", + st.session_state["__nf_busca_opts__"], + key="sel_registro_base" + ) + + if st.button("📥 Usar este registro como base", key="btn_usar_base"): + try: + # Extrai o ID do texto selecionado + chosen_id = int(escolha.split()[1]) + except Exception: + st.error("Falha ao identificar o ID do registro selecionado.") + chosen_id = None + + if chosen_id: + # Busca o registro no banco por ID (seguro e stateless) + db = SessionLocal() + try: + base = db.query(Equipamento).filter(Equipamento.id == chosen_id).first() + except Exception as e: + base = None + st.error(f"Erro ao buscar o registro por ID: {e}") + finally: + db.close() + + if not base: + st.error("Registro não encontrado. Tente buscar novamente.") + else: + pre = _build_prefill_from_record(base) + if marcar_origem: + pre["observacoes"] = (pre.get("observacoes") or "") + \ + f" [Clonado do ID {base.id} em {datetime.now():%d/%m/%Y %H:%M}]" + st.session_state["form_prefill"] = pre + st.session_state.pop("__prefill_applied__", None) + st.success("Pré-preenchimento carregado! Role até o formulário para revisar.") + st.rerun() + + # ====================================================== + # APLICAR PREFILL (antes de criar os widgets) + # ====================================================== + prefill = _ss_get("form_prefill", {}) + _apply_prefill_to_state(prefill, debug=debug) + + if debug: + with st.expander("🔎 DEBUG • session_state (parcial)"): + keys = [k for k in st.session_state.keys() if k.startswith("w_") or k in ("form_prefill", "__prefill_applied__", "__nf_busca_result__", "__nf_busca_opts__", "sel_registro_base")] + dump = {k: st.session_state[k] for k in sorted(keys)} + st.write(dump) + + # ====================================================== + # FORMULÁRIO (SEM st.form) + # ====================================================== + + # -------- FPSO -------- + st.subheader("🚢 Identificação FPSO") + c1, c2 = st.columns(2) + + lista_fpso1 = get_fpsos1() + ["➕ Novo FPSO1"] + lista_fpso = get_fpsos() + ["➕ Novo FPSO"] + + # Se valor não está na lista, muda para "➕ Novo …" e mantém texto + if _ss_get("w_fpso1", "") not in lista_fpso1: + _ss_set("w_fpso1", "➕ Novo FPSO1" if _ss_get("w_fpso1_text") else "") + if _ss_get("w_fpso", "") not in lista_fpso: + _ss_set("w_fpso", "➕ Novo FPSO" if _ss_get("w_fpso_text") else "") + + with c1: + st.selectbox( + "FPSO1 *", + lista_fpso1, + index=_safe_index(lista_fpso1, _ss_get("w_fpso1", "")), + key="w_fpso1" + ) + if _ss_get("w_fpso1") == "➕ Novo FPSO1": + st.text_input("Digite o novo FPSO1 *", key="w_fpso1_text") + fpso1 = _ss_get("w_fpso1_text", "").strip() + else: + _ss_set("w_fpso1_text", _ss_get("w_fpso1")) + fpso1 = _ss_get("w_fpso1") + + with c2: + st.selectbox( + "FPSO *", + lista_fpso, + index=_safe_index(lista_fpso, _ss_get("w_fpso", "")), + key="w_fpso" + ) + if _ss_get("w_fpso") == "➕ Novo FPSO": + st.text_input("Digite o novo FPSO *", key="w_fpso_text") + fpso = _ss_get("w_fpso_text", "").strip() + else: + _ss_set("w_fpso_text", _ss_get("w_fpso")) + fpso = _ss_get("w_fpso") + + st.divider() + + # -------- Dados Operacionais -------- + st.subheader("📦 Dados Operacionais") + c1, c2, c3 = st.columns(3) + + with c1: + # 🟢 Usa valor do estado; se vier None, o widget ainda precisa de um date válido. + st.date_input( + "Data de Coleta na ARM *", + value=_ss_get("w_data_coleta") or date.today(), + key="w_data_coleta" + ) + st.text_input("Especialista Responsável *", key="w_especialista") + st.text_input("Conferente Responsável *", key="w_conferente") + st.text_input("OSM *", key="w_osm") + + with c2: + st.selectbox("Modal *", MODAL_LISTA, + index=_safe_index(MODAL_LISTA, _ss_get("w_modal", "")), + key="w_modal") + st.number_input("Quantidade de Equipamentos *", min_value=0, value=_ss_get("w_quant_equip", 0), key="w_quant_equip") + st.text_input("MROB *", key="w_mrob") + + with c3: + st.number_input("Total de Linhas OSM", min_value=0, value=_ss_get("w_linhas_osm", 0), key="w_linhas_osm") + st.number_input("Total de Linhas MROB", min_value=0, value=_ss_get("w_linhas_mrob", 0), key="w_linhas_mrob") + st.number_input("Total de Linhas com Erro", min_value=0, value=_ss_get("w_linhas_erros", 0), key="w_linhas_erros") + + st.divider() + + # -------- Análise de Erros -------- + st.subheader("⚠️ Análise de Erros") + c1, c2, c3, c4 = st.columns(4) + c1.selectbox("Storekeeper", RESP_ERRO_LISTA, index=_safe_index(RESP_ERRO_LISTA, _ss_get("w_erro_storekeeper", "")), key="w_erro_storekeeper") + c2.selectbox("Operação WH", RESP_ERRO_LISTA, index=_safe_index(RESP_ERRO_LISTA, _ss_get("w_erro_operacao", "")), key="w_erro_operacao") + c3.selectbox("Especialista WH", RESP_ERRO_LISTA, index=_safe_index(RESP_ERRO_LISTA, _ss_get("w_erro_especialista", "")), key="w_erro_especialista") + c4.selectbox("Outros", RESP_ERRO_LISTA, index=_safe_index(RESP_ERRO_LISTA, _ss_get("w_erro_outros", "")), key="w_erro_outros") + + st.selectbox( + "Inclusão / Exclusão", + INCLUSAO_EXCLUSAO_LISTA, + index=_safe_index(INCLUSAO_EXCLUSAO_LISTA, _ss_get("w_inclusao_exclusao", "")), + key="w_inclusao_exclusao" + ) + + st.divider() + + # -------- Dados Administrativos -------- + st.subheader("🧾 Dados Administrativos") + ca, cb, cc = st.columns(3) + with ca: + st.text_input("PO", key="w_po") + st.text_input("Part Number", key="w_part_number") + with cb: + st.text_input("Material", key="w_material") + st.text_input("Nota Fiscal *", key="w_nota_fiscal") + with cc: + st.text_input("Solicitante *", key="w_solicitante") + st.text_input("Requisitante *", key="w_requisitante") + + st.text_input("Impacto *", key="w_impacto") + st.text_input("Dimensão *", key="w_dimensao") + st.text_input("Motivo da Inclusão / Exclusão", key="w_motivo") + + st.text_area("Observações", key="w_observacoes", height=120) + + # -------- Dia D -------- + st.subheader("🗓️ Dia de Inclusão (D)") + st.selectbox( + "Selecione o dia", + D_LISTA, + index=_safe_index(D_LISTA, _ss_get("w_dia_inclusao", "")), + key="w_dia_inclusao" + ) + + # ====================================================== + # SALVAR (sempre nova entrada) + # ====================================================== + if st.button("💾 Salvar Registro", type="primary"): + # Lê valores atuais do session_state + data_coleta = _ss_get("w_data_coleta") + especialista = _ss_get("w_especialista", "").strip() + conferente = _ss_get("w_conferente", "").strip() + osm = _ss_get("w_osm", "").strip() + modal = _ss_get("w_modal", "") + quant_equip = int(_ss_get("w_quant_equip", 0) or 0) + mrob = _ss_get("w_mrob", "").strip() + linhas_osm = int(_ss_get("w_linhas_osm", 0) or 0) + linhas_mrob = int(_ss_get("w_linhas_mrob", 0) or 0) + linhas_erros = int(_ss_get("w_linhas_erros", 0) or 0) + erro_storekeeper = _ss_get("w_erro_storekeeper", "") + erro_operacao = _ss_get("w_erro_operacao", "") + erro_especialista = _ss_get("w_erro_especialista", "") + erro_outros = _ss_get("w_erro_outros", "") + inclusao_exclusao = _ss_get("w_inclusao_exclusao", "") + po = _ss_get("w_po", "").strip() + part_number = _ss_get("w_part_number", "").strip() + material = _ss_get("w_material", "").strip() + solicitante = _ss_get("w_solicitante", "").strip() + requisitante = _ss_get("w_requisitante", "").strip() + nota_fiscal = _ss_get("w_nota_fiscal", "").strip() + impacto = _ss_get("w_impacto", "").strip() + dimensao = _ss_get("w_dimensao", "").strip() + motivo = _ss_get("w_motivo", "").strip() + observacoes = _ss_get("w_observacoes", "").strip() + dia_inclusao = _ss_get("w_dia_inclusao", "") + + # Campos FPSO/FPSO1 + fpso1 = _ss_get("w_fpso1_text", "").strip() if _ss_get("w_fpso1") == "➕ Novo FPSO1" else _ss_get("w_fpso1", "").strip() + fpso = _ss_get("w_fpso_text", "").strip() if _ss_get("w_fpso") == "➕ Novo FPSO" else _ss_get("w_fpso", "").strip() + + obrigatorios = { + "FPSO1": fpso1, + "FPSO": fpso, + "Especialista": especialista, + "Conferente": conferente, + "OSM": osm, + "Modal": modal, + "MROB": mrob, + "Solicitante": solicitante, + "Requisitante": requisitante, + "Nota Fiscal": nota_fiscal, + "Impacto": impacto, + "Dimensão": dimensao, + "Dia de Inclusão (D)": dia_inclusao, + } + faltantes = [k for k, v in obrigatorios.items() if not v] + if faltantes: + st.error("❌ Campos obrigatórios não preenchidos: " + ", ".join(faltantes)) + return + + db = SessionLocal() + try: + novo = Equipamento( + fpso1=fpso1, + fpso=fpso, + data_coleta=data_coleta, + especialista=especialista, + conferente=conferente, + osm=osm, + modal=modal, + quant_equip=quant_equip, + mrob=mrob, + linhas_osm=linhas_osm, + linhas_mrob=linhas_mrob, + linhas_erros=linhas_erros, + erro_storekeeper=erro_storekeeper, + erro_operacao=erro_operacao, + erro_especialista=erro_especialista, + erro_outros=erro_outros, + inclusao_exclusao=inclusao_exclusao, + po=po, + part_number=part_number, + material=material, + solicitante=solicitante, + motivo=motivo, + requisitante=requisitante, + nota_fiscal=nota_fiscal, + impacto=impacto, + dimensao=dimensao, + observacoes=observacoes, + dia_inclusao=dia_inclusao, + data_hora_input=datetime.now(), + ) + db.add(novo) + db.commit() + + registrar_log( + usuario=_ss_get("usuario", "desconhecido"), + acao="INSERIR", + tabela="equipamentos", + registro_id=novo.id + ) + + st.success("✅ Registro salvo com sucesso!") + st.rerun() + + except Exception as e: + db.rollback() + st.error(f"❌ Erro ao salvar: {e}") + finally: + db.close() + +if __name__ == "__main__": + main() diff --git a/importar_excel.py b/importar_excel.py new file mode 100644 index 0000000000000000000000000000000000000000..1a96aa02abf2abb6aa16635ac52cd7bceea66f30 --- /dev/null +++ b/importar_excel.py @@ -0,0 +1,301 @@ + +import streamlit as st +import pandas as pd +from io import BytesIO +from datetime import datetime, date + +from banco import SessionLocal +from models import Equipamento +from utils_auditoria import registrar_log + + +# ===================================================== +# FUNÇÕES AUXILIARES +# ===================================================== +def to_date(value): + """ + Converte pandas.Timestamp ou datetime para datetime.date + Necessário para compatibilidade com SQLite + """ + if value is None or pd.isna(value): + return None + if isinstance(value, pd.Timestamp): + return value.date() + if isinstance(value, datetime): + return value.date() + if isinstance(value, date): + return value + return None + + +def safe_value(value): + """ + Retorna 0 se o valor for vazio/NaN, senão retorna o próprio valor. + Usado para campos obrigatórios. + """ + if value is None or pd.isna(value): + return 0 + return value + + +# ===================================================== +# MÓDULO PRINCIPAL +# ===================================================== +def main(): + st.title("📥 Importação de Dados via Excel") + + st.markdown( + """ + Este módulo permite: + - 📄 Baixar um **modelo Excel padrão** + - ✍️ Preencher os dados offline + - 🔍 Validar antes da gravação + - 💾 Importar os registros para o banco + """ + ) + + # ===================================================== + # 1️⃣ GERAR MODELO EXCEL + # ===================================================== + st.subheader("📄 Baixar modelo Excel") + + colunas = [ + "fpso1", "fpso", "data_coleta", "especialista", "conferente", "osm", "modal", + "quant_equip", "mrob", "linhas_osm", "linhas_mrob", "linhas_erros", + "erro_storekeeper", "erro_operacao", "erro_especialista", "erro_outros", + "inclusao_exclusao", "po", "part_number", "material", "solicitante", "motivo", + "requisitante", "nota_fiscal", "impacto", "dimensao", "observacoes", "dia_inclusao", + ] + + modelo_df = pd.DataFrame(columns=colunas) + + buffer = BytesIO() + with pd.ExcelWriter(buffer, engine="openpyxl") as writer: + modelo_df.to_excel(writer, index=False, sheet_name="MODELO") + + buffer.seek(0) + + st.download_button( + label="⬇️ Baixar modelo Excel", + data=buffer, + file_name="modelo_importacao_load.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ) + + st.divider() + + # ===================================================== + # 2️⃣ UPLOAD DO ARQUIVO + # ===================================================== + st.subheader("📤 Importar arquivo preenchido") + + arquivo = st.file_uploader( + "Selecione o arquivo Excel", + type=["xlsx"] + ) + + if not arquivo: + st.info("📌 Faça o upload de um arquivo para continuar.") + return + + try: + df = pd.read_excel(arquivo) + except Exception as e: + st.error(f"❌ Erro ao ler o arquivo: {e}") + return + + # Garante um identificador estável para cada linha durante as edições + if "_row_id" not in df.columns: + df["_row_id"] = range(len(df)) + + # Salva o DF bruto na sessão para persistência entre reruns + st.session_state["df_raw"] = df.copy() + + st.success("✅ Arquivo carregado com sucesso!") + + # ===================================================== + # 3️⃣ PRÉVIA DOS DADOS + # ===================================================== + st.subheader("🔍 Prévia dos dados") + st.dataframe(df, use_container_width=True) + + # ===================================================== + # 4️⃣ VERIFICAÇÃO DE DUPLICIDADE (com seleção de linhas a excluir) + # ===================================================== + st.subheader("🧪 Verificação de duplicidade") + + st.caption("Escolha as colunas que definem a duplicidade. Em seguida, marque o checkbox **_excluir** nas linhas que **não** deseja importar.") + + # Sugerimos um conjunto padrão de chaves, mas só usamos as que existem no arquivo + sugestao_chaves = [c for c in ["fpso", "osm", "po", "part_number", "nota_fiscal", "data_coleta"] if c in df.columns] + + chaves = st.multiselect( + "📌 Colunas para verificação de duplicidade:", + options=list(df.columns), + default=sugestao_chaves if len(sugestao_chaves) > 0 else [] + ) + + # Prepara um DF de trabalho com colunas auxiliares + work_df = df.copy() + work_df["_duplicado"] = False + work_df["_excluir"] = False # será editado pelo usuário + + if len(chaves) == 0: + st.info("Selecione ao menos **uma** coluna para verificar duplicidade.") + # Mesmo sem duplicidade definida, permitimos marcar exclusões manuais, se quiser: + with st.expander("🔧 (Opcional) Excluir linhas manualmente mesmo sem duplicidade"): + manual_view = work_df.set_index("_row_id")[ + [c for c in work_df.columns if c not in ["_duplicado"]] + ["_excluir"] + ] + edited_manual = st.data_editor( + manual_view, + use_container_width=True, + num_rows="fixed", + column_config={ + "_excluir": st.column_config.CheckboxColumn("Excluir da importação") + } + ) + # Aplica exclusões manuais + work_df = work_df.set_index("_row_id") + work_df["_excluir"] = edited_manual["_excluir"].reindex(work_df.index).fillna(False).astype(bool) + work_df = work_df.reset_index() + + else: + # Marca duplicadas com base nas chaves selecionadas + mask_dup_any = work_df.duplicated(subset=chaves, keep=False) + work_df["_duplicado"] = mask_dup_any + + # Sugerimos exclusão automática das ocorrências não-primárias (o usuário pode alterar) + mask_dup_not_first = work_df.duplicated(subset=chaves, keep="first") + work_df.loc[mask_dup_not_first, "_excluir"] = True + + if mask_dup_any.any(): + st.warning("⚠️ Foram encontradas linhas duplicadas com base nas chaves selecionadas:") + # Mostrar somente as duplicadas para facilitar a decisão + cols_para_mostrar = chaves + [c for c in ["_duplicado", "_excluir"] if c not in chaves] + # Evita colunas repetidas mantendo ordem + seen = set() + cols_para_mostrar = [c for c in cols_para_mostrar if not (c in seen or seen.add(c))] + + dup_view = work_df.loc[mask_dup_any].set_index("_row_id")[cols_para_mostrar] + edited_dup = st.data_editor( + dup_view, + use_container_width=True, + num_rows="fixed", + column_config={ + "_excluir": st.column_config.CheckboxColumn("Excluir da importação"), + "_duplicado": st.column_config.CheckboxColumn("Duplicado", disabled=True) + } + ) + + # Mescla de volta as escolhas do usuário + work_df = work_df.set_index("_row_id") + if "_excluir" in edited_dup.columns: + work_df.loc[edited_dup.index, "_excluir"] = ( + edited_dup["_excluir"].reindex(work_df.index).fillna(work_df["_excluir"]).astype(bool) + ) + work_df = work_df.reset_index() + + st.info( + f"📊 Totais — Linhas: {len(work_df)} | Duplicadas: {mask_dup_any.sum()} | " + f"Marcadas para excluir: {int(work_df['_excluir'].sum())}" + ) + else: + st.success("✅ Nenhuma duplicidade encontrada com as chaves selecionadas.") + + st.divider() + + # ===================================================== + # 4.1️⃣ Prévia final do que será importado + download + # ===================================================== + df_para_importar = work_df[~work_df["_excluir"]].drop(columns=["_duplicado", "_excluir"], errors="ignore") + st.session_state["df_para_importar"] = df_para_importar.copy() + + st.subheader("🧾 Prévia do que será importado") + st.caption("A prévia abaixo desconsidera as linhas marcadas como **_excluir**.") + st.dataframe(df_para_importar.drop(columns=["_row_id"], errors="ignore"), use_container_width=True) + + # Download da prévia para conferência + buf_prev = BytesIO() + with pd.ExcelWriter(buf_prev, engine="openpyxl") as writer: + df_para_importar.drop(columns=["_row_id"], errors="ignore").to_excel(writer, index=False, sheet_name="A_IMPORTAR") + buf_prev.seek(0) + st.download_button( + "⬇️ Baixar prévia (Excel)", + data=buf_prev, + file_name="previa_a_importar.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + help="Baixe a prévia do conjunto que será gravado." + ) + + # ===================================================== + # 5️⃣ GRAVAÇÃO NO BANCO (usa o DataFrame filtrado) + # ===================================================== + st.subheader("💾 Gravar dados no banco") + + col1, col2 = st.columns(2) + + if col1.button("💾 Salvar registros importados"): + df_import = st.session_state.get("df_para_importar", df) + + if df_import.empty: + st.error("Não há registros para importar. Revise as exclusões.") + return + + with SessionLocal() as db: + try: + for _, row in df_import.iterrows(): + registro = Equipamento( + fpso1=safe_value(row.get("fpso1")), + fpso=safe_value(row.get("fpso")), + data_coleta=to_date(row.get("data_coleta")), + especialista=safe_value(row.get("especialista")), + conferente=safe_value(row.get("conferente")), + osm=safe_value(row.get("osm")), + modal=safe_value(row.get("modal")), + quant_equip=int(row["quant_equip"]) if not pd.isna(row.get("quant_equip")) else 0, + mrob=safe_value(row.get("mrob")), + linhas_osm=int(row["linhas_osm"]) if not pd.isna(row.get("linhas_osm")) else 0, + linhas_mrob=int(row["linhas_mrob"]) if not pd.isna(row.get("linhas_mrob")) else 0, + linhas_erros=int(row["linhas_erros"]) if not pd.isna(row.get("linhas_erros")) else 0, + erro_storekeeper=safe_value(row.get("erro_storekeeper")), + erro_operacao=safe_value(row.get("erro_operacao")), + erro_especialista=safe_value(row.get("erro_especialista")), + erro_outros=safe_value(row.get("erro_outros")), + inclusao_exclusao=safe_value(row.get("inclusao_exclusao")), + po=safe_value(row.get("po")), + part_number=safe_value(row.get("part_number")), + material=safe_value(row.get("material")), + solicitante=safe_value(row.get("solicitante")), + motivo=safe_value(row.get("motivo")), + requisitante=safe_value(row.get("requisitante")), + nota_fiscal=safe_value(row.get("nota_fiscal")), + impacto=safe_value(row.get("impacto")), + dimensao=safe_value(row.get("dimensao")), + observacoes=safe_value(row.get("observacoes")), + dia_inclusao=safe_value(row.get("dia_inclusao")), + ) + db.add(registro) + + db.commit() + + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"IMPORTAÇÃO EXCEL ({len(df_import)} registros) - com filtro de duplicidade", + tabela="equipamentos", + registro_id=None + ) + + st.success(f"🎉 Importação concluída com sucesso! {len(df_import)} registros gravados.") + + except Exception as e: + db.rollback() + st.error(f"❌ Erro ao gravar no banco: {e}") + + if col2.button("❌ Cancelar importação"): + st.warning("Importação cancelada pelo usuário.") + + +if __name__ == "__main__": + main() + diff --git a/init_admin.py b/init_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..cf30b8d737e3ce35965bc4533fd0bd3b3efb7923 --- /dev/null +++ b/init_admin.py @@ -0,0 +1,33 @@ +# init_admin.py +from banco import SessionLocal, engine, Base +from models import Usuario +from werkzeug.security import generate_password_hash + +def criar_admin(): + Base.metadata.create_all(bind=engine) + + db = SessionLocal() + + admin = db.query(Usuario).filter(Usuario.usuario == "admin").first() + + if admin: + print("✔ Usuário admin já existe") + return + + admin = Usuario( + usuario="admin", + senha=generate_password_hash("admin123"), + perfil="admin", + ativo=True + ) + + db.add(admin) + db.commit() + db.close() + + print("✅ Usuário admin criado com sucesso") + print("Login: admin") + print("Senha: admin123") + +if __name__ == "__main__": + criar_admin() diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000000000000000000000000000000000000..cfe7b111e858baec7a8c525bdc94dd1dfb3e6006 --- /dev/null +++ b/init_db.py @@ -0,0 +1,38 @@ +from banco import engine, SessionLocal, Base +import models +from models import Usuario +from utils_seguranca import gerar_hash_senha + +# Cria todas as tabelas definidas nos modelos +Base.metadata.create_all(bind=engine) + +db = SessionLocal() + +try: + usuarios_padrao = [ + ("admin", "admin123", "admin"), + ("usuario", "usuario123", "usuario"), + ("consulta", "consulta123", "consulta"), + ] + + for nome, senha, perfil in usuarios_padrao: + existe = db.query(Usuario).filter(Usuario.usuario == nome).first() + + if not existe: + novo = Usuario( + usuario=nome, + senha=gerar_hash_senha(senha), + perfil=perfil, + ativo=True + ) + db.add(novo) + print(f"✅ Usuário '{nome}' criado") + else: + print(f"ℹ️ Usuário '{nome}' já existe") + + db.commit() + +finally: + db.close() + +print("✅ Banco inicializado com sucesso!") diff --git a/jogos.py b/jogos.py new file mode 100644 index 0000000000000000000000000000000000000000..eeb2bc31587c7b44081d005f409147bf0e249847 --- /dev/null +++ b/jogos.py @@ -0,0 +1,384 @@ + +import streamlit as st +import random +import unicodedata +from datetime import date, timedelta # ✅ para controle diário de tentativas falhas + + +def reset_keys(keys): + for k in keys: + st.session_state.pop(k, None) + + +def strip_accents(s: str) -> str: # ✅ corrigido '->' (sem entidades HTML) + if not isinstance(s, str): + return s + return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") + + +# ✅ Helper de estado: garantia defensiva (executa sem impactar sua lógica) +def _init_estado_jogos(): + st.session_state.setdefault("pontuacao", 0) + st.session_state.setdefault("rodadas", 0) + st.session_state.setdefault("ultimo_resultado", None) + # ⬇️ NOVO (Dado): memória de resultados já vistos por configuração de lados + st.session_state.setdefault("dado_resultados_vistos", []) + st.session_state.setdefault("dado_lados_atual", None) + # ⬇️ NOVO (Forca): palavras já concluídas por categoria (evita pontuar repetidas) + # Estrutura: { "FPSO": ["INSPECAO VISUAL", ...], "Estoque e Armazenagens": [...], ... } + st.session_state.setdefault("forca_palavras_vistas", {}) + # ⬇️ NOVO (Forca): letras já clicadas (apagadas/desativadas no teclado) + st.session_state.setdefault("forca_letras_usadas", set()) + # ⬇️ NOVO (Forca): controle diário por rodada perdida (tentativa de jogo falha) + st.session_state.setdefault("forca_falhas_hoje", 0) + st.session_state.setdefault("forca_block_expires_on", None) + st.session_state.setdefault("forca_data_ref", str(date.today())) + st.session_state.setdefault("forca_round_contabilizado", False) # marca se a perda já foi contabilizada nesta rodada + # ⬇️ NOVO (Tesouro): perguntas já concluídas por categoria (evita pontuar repetidas) + # Estrutura: { "FPSO": ["Identifique o item correto...", ...], ... } + st.session_state.setdefault("tesouro_perguntas_vistas", {}) + + +# Mantém inicialização que você tinha (compatível com o helper) +if "pontuacao" not in st.session_state: + st.session_state.pontuacao = 0 + + +FORCA_BANK = [ + { + "categoria": "FPSO", + "pergunta": "Qual procedimento prioriza a integridade de carga no deck?", + "resposta": "INSPEÇÃO VISUAL", + }, + { + "categoria": "Estoque e Armazenagens", + "pergunta": "Qual política prioriza itens com menor validade remanescente?", + "resposta": "FEFO", + }, + { + "categoria": "Óleo e Gás", + "pergunta": "Qual documento acompanha movimentação de resíduos perigosos?", + "resposta": "MANIFESTO DE RESÍDUOS", + }, +] + +TESOURO_BANK = [ + { + "categoria": "FPSO", + "pergunta": "Identifique o item correto para amarração segura no deck.", + "pistas": [ + {"texto": "Requer inspeção visual antes do uso.", "correto": True}, + {"texto": "É descartável após uma operação.", "correto": False}, + {"texto": "Possui etiqueta de carga segura com WLL.", "correto": True}, + {"texto": "Não pode ser usado em ambiente offshore.", "correto": False}, + ], + }, + { + "categoria": "Estoque e Armazenagens", + "pergunta": "Determine a política correta para expedição de produtos com validade.", + "pistas": [ + {"texto": "Prioriza vencimento mais próximo.", "correto": True}, + {"texto": "Ignora datas de validade.", "correto": False}, + ], + }, +] + + +def jogo_dado(): + _init_estado_jogos() # ✅ garante chaves antes do uso + st.subheader("🎲 Jogo do Dado (Curiosidades)") + + curiosidades = [ + "FIFO e FEFO impactam diretamente a acuracidade de estoque.", + "Cross-docking reduz tempos e evita armazenagem desnecessária.", + "WMS integra endereçamento, picking e inventários cíclicos.", + ] + + lados = st.slider("Escolha o número de lados do dado:", 6, 20, 8) + + # ⬇️ NOVO: ao alterar o número de lados, resetamos os resultados vistos para esta configuração + if st.session_state.dado_lados_atual != lados: + st.session_state.dado_lados_atual = lados + st.session_state.dado_resultados_vistos = [] # limpa histórico para a nova configuração de lados + + if st.button("Girar dado"): + resultado = random.randint(1, lados) + st.success(f"🎲 Você rolou: {resultado}") + st.info(curiosidades[resultado % len(curiosidades)]) + + # ⬇️ NOVO: só pontua se o resultado ainda NÃO tiver saído nesta configuração de lados + if resultado in st.session_state.dado_resultados_vistos: + st.warning("🔁 Resultado repetido — nenhum ponto adicionado ao ranking.") + else: + st.session_state.dado_resultados_vistos.append(resultado) + st.session_state.pontuacao += 5 + st.balloons() + + # Informações úteis + if st.session_state.dado_resultados_vistos: + vistos_fmt = ", ".join(str(v) for v in sorted(st.session_state.dado_resultados_vistos)) + st.caption(f"🔎 Valores únicos já obtidos com {lados} lados: {vistos_fmt}") + + st.write(f"Pontuação atual: {st.session_state.pontuacao}") + + +def jogo_forca_treinamento(): + _init_estado_jogos() # ✅ garante chaves antes do uso + st.subheader("🔤 Jogo da Forca (Treinamento)") + + # 🔧 Reset diário dos contadores/bloqueio por rodada perdida + if st.session_state.get("forca_data_ref") != str(date.today()): + st.session_state.forca_data_ref = str(date.today()) + st.session_state.forca_falhas_hoje = 0 + st.session_state.forca_block_expires_on = None + + # 🔒 Se bloqueado até amanhã, impedir jogar + block_until = st.session_state.get("forca_block_expires_on") + if block_until and date.today() < block_until: + st.error(f"⏳ Você atingiu 3 tentativas falhas hoje. Tente novamente em {block_until.strftime('%d/%m/%Y')}.") + st.write(f"Pontuação atual: {st.session_state.pontuacao}") + return + + categorias = sorted(set(q["categoria"] for q in FORCA_BANK)) + cat_sel = st.selectbox("Categoria:", ["Todas"] + categorias, index=0) + + banco_filtrado = [q for q in FORCA_BANK if cat_sel == "Todas" or q["categoria"] == cat_sel] + total = len(banco_filtrado) + + # Reset ao trocar categoria ou na primeira carga + if "forca_idx" not in st.session_state or st.session_state.get("forca_cat") != cat_sel: + st.session_state.forca_idx = 0 + st.session_state.forca_cat = cat_sel + reset_keys(["forca_palavra", "letras_descobertas", "tentativas", "letras_erradas", "forca_input", "forca_letras_usadas", "forca_round_contabilizado"]) + + if not banco_filtrado: + st.warning("Não há perguntas para esta categoria.") + return + + nivel = st.session_state.forca_idx % total + st.write(f"Progresso: Pergunta {nivel + 1}/{total}") + atual = banco_filtrado[nivel] + pergunta, resposta = atual["pergunta"], atual["resposta"].upper() + categoria_atual = atual["categoria"] + + # Inicializa rodada da forca + if "forca_palavra" not in st.session_state or st.session_state.get("forca_palavra") != resposta: + st.session_state.forca_palavra = resposta + st.session_state.letras_descobertas = ["_" if c.isalpha() else c for c in resposta] + st.session_state.tentativas = 6 + st.session_state.letras_erradas = [] + st.session_state.forca_letras_usadas = set() # ⬅️ inicia teclado limpo + st.session_state.forca_round_contabilizado = False # ⬅️ nova rodada ainda não contabilizada como falha + + st.write(f"Pergunta: {pergunta}") + st.write("Palavra:", " ".join(st.session_state.letras_descobertas)) + st.write(f"Tentativas restantes: {st.session_state.tentativas}") + + # ============================== + # 🔤 NOVO: TECLADO DE ALFABETO (compacto) + # ============================== + # CSS para deixar botões mais juntos (afeta globalmente — usar com parcimônia) + st.markdown( + """ + + """, + unsafe_allow_html=True + ) + + # A–Z; comparação sem acentos (ex.: 'O' casa 'Ó', 'C' casa 'Ç') + alfabeto = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ") + + # Duas linhas com 13 letras cada (mais compacto) + linhas = [ + alfabeto[0:13], + alfabeto[13:26], + ] + + for idx_linha, linha in enumerate(linhas): + cols = st.columns(len(linha)) + for idx_letra, letra in enumerate(linha): + usada = letra in st.session_state.forca_letras_usadas + if cols[idx_letra].button(letra, key=f"forca_btn_{nivel}_{letra}", disabled=usada): + # Marca como usada (apagada) + st.session_state.forca_letras_usadas.add(letra) + + # Verifica se a letra existe na palavra (comparando sem acentos) + hit = False + for i, ch in enumerate(st.session_state.forca_palavra): + if strip_accents(ch) == strip_accents(letra): + st.session_state.letras_descobertas[i] = ch + hit = True + + if not hit: + # Letra errada: registra e decrementa tentativa + if letra not in st.session_state.letras_erradas: + st.session_state.letras_erradas.append(letra) + st.session_state.tentativas -= 1 + + # Rerender após o clique para atualizar visualmente a palavra e tentativas + st.rerun() + + # Feedback de letras erradas (visível) + if st.session_state.letras_erradas: + st.caption(f"❌ Letras erradas: {', '.join(st.session_state.letras_erradas)}") + + venceu = "_" not in st.session_state.letras_descobertas + perdeu = st.session_state.tentativas <= 0 # ✅ corrigido '<=' (sem entidades HTML) + + # ⬇️ NOVO: prepara lista de palavras já vistas por categoria + # normaliza removendo acentos para evitar duplicidades por grafia + normalizada = strip_accents(resposta) + + if venceu: + # Checa repetição por categoria e evita somar pontos + vistas = st.session_state.forca_palavras_vistas.get(categoria_atual, []) + if normalizada in vistas: + st.warning("🔁 Palavra repetida nesta categoria — nenhum ponto adicionado ao ranking.") + else: + vistas.append(normalizada) + st.session_state.forca_palavras_vistas[categoria_atual] = vistas + st.success("🎉 Você venceu! Palavra completa.") + st.session_state.pontuacao += 10 + st.balloons() + + # Mostrar resumo de palavras únicas concluídas nesta categoria + if st.session_state.forca_palavras_vistas.get(categoria_atual): + lista_fmt = ", ".join(st.session_state.forca_palavras_vistas[categoria_atual]) + st.caption(f"🔎 Palavras únicas concluídas ({categoria_atual}): {lista_fmt}") + + if nivel + 1 < total: # ✅ corrigido '<' (sem entidades HTML) + if st.button("➡️ Próxima pergunta"): + st.session_state.forca_idx += 1 + reset_keys(["forca_palavra", "letras_descobertas", "tentativas", "letras_erradas", "forca_input", "forca_letras_usadas", "forca_round_contabilizado"]) + st.rerun() + else: + st.success("🏆 Você finalizou todas as perguntas do jogo da Forca!") + + elif perdeu: + # ✅ NOVO: contabiliza tentativa falha (rodada perdida) uma única vez + if not st.session_state.forca_round_contabilizado: + st.session_state.forca_falhas_hoje += 1 + st.session_state.forca_round_contabilizado = True + + # 🔒 Bloqueia após 3 tentativas falhas no dia + if st.session_state.forca_falhas_hoje >= 3: + st.session_state.forca_block_expires_on = date.today() + timedelta(days=1) + st.error( + f"⏳ Limite diário atingido (3 tentativas falhas). " + f"Tente novamente em {st.session_state.forca_block_expires_on.strftime('%d/%m/%Y')}." + ) + + st.error(f"💀 Você perdeu! A palavra era: {st.session_state.forca_palavra}") + + st.write(f"Pontuação atual: {st.session_state.pontuacao}") + + +def jogo_tesouro_niveis(): + _init_estado_jogos() # ✅ garante chaves antes do uso + st.subheader("🗺️ Caça ao Tesouro (Níveis)") + + categorias = sorted(set(q["categoria"] for q in TESOURO_BANK)) + cat_sel = st.selectbox("Categoria:", ["Todas"] + categorias, index=0) + + banco_filtrado = [q for q in TESOURO_BANK if cat_sel == "Todas" or q["categoria"] == cat_sel] + total = min(100, len(banco_filtrado)) + + # Reset ao trocar categoria ou primeira carga + if "tes_idx" not in st.session_state or st.session_state.get("tes_cat") != cat_sel: + st.session_state.tes_idx = 0 + st.session_state.tes_cat = cat_sel + reset_keys(["tes_respostas", "tes_concluido"]) + + if total == 0: + st.warning("Não há perguntas para esta categoria.") + return + + nivel = st.session_state.tes_idx % total + st.write(f"Progresso: Nível {nivel + 1}/{total}") + atual = banco_filtrado[nivel] + pergunta_atual = atual["pergunta"] + categoria_atual = atual["categoria"] + st.write(f"Pergunta: {pergunta_atual}") + + if "tes_respostas" not in st.session_state: + st.session_state.tes_respostas = {i: None for i in range(len(atual["pistas"]))} + st.session_state.tes_concluido = False + + for i, pista in enumerate(atual["pistas"]): + cols = st.columns([6, 1, 1]) + cols[0].write(f"🧩 {pista['texto']}") + if cols[1].button("Sim", key=f"tes_sim_{nivel}_{i}"): + st.session_state.tes_respostas[i] = True + if cols[2].button("Não", key=f"tes_nao_{nivel}_{i}"): + st.session_state.tes_respostas[i] = False + + r = st.session_state.tes_respostas[i] + if r is not None: + if r == pista["correto"]: + cols[0].success("✅ Correto") + else: + cols[0].error("❌ Incorreto") + + tudo_respondido = all(v is not None for v in st.session_state.tes_respostas.values()) + tudo_correto = tudo_respondido and all( + st.session_state.tes_respostas[i] == p["correto"] for i, p in enumerate(atual["pistas"]) + ) + + # ⬇️ NOVO: prepara lista de perguntas já concluídas por categoria + pergunta_norm = strip_accents(pergunta_atual).upper() + + if tudo_correto: + st.session_state.tes_concluido = True + + # Checa repetição por categoria e evita somar pontos + vistas = st.session_state.tesouro_perguntas_vistas.get(categoria_atual, []) + if pergunta_norm in vistas: + st.warning("🔁 Nível/pergunta já concluído anteriormente — nenhum ponto adicionado ao ranking.") + else: + vistas.append(pergunta_norm) + st.session_state.tesouro_perguntas_vistas[categoria_atual] = vistas + st.success("🎉 Parabéns! Nível concluído.") + st.session_state.pontuacao += 10 + st.balloons() + + # Mostrar resumo de perguntas únicas concluídas nesta categoria + if st.session_state.tesouro_perguntas_vistas.get(categoria_atual): + lista_fmt = ", ".join(st.session_state.tesouro_perguntas_vistas[categoria_atual]) + st.caption(f"🔎 Perguntas únicas concluídas ({categoria_atual}): {lista_fmt}") + + if nivel + 1 < total: # ✅ corrigido '<' (sem entidades HTML) + if st.button("➡️ Avançar para o próximo nível"): + st.session_state.tes_idx += 1 + reset_keys(["tes_respostas", "tes_concluido"]) + st.experimental_rerun() + else: + st.success("🏆 Você finalizou todos os níveis do Caça ao Tesouro!") + + st.write(f"Pontuação atual: {st.session_state.pontuacao}") + + +def main(): + _init_estado_jogos() # ✅ garante estado ao entrar na página Jogos + st.title("🎮 Jogos Interativos para Treinamento") + + jogo = st.selectbox( + "Escolha um jogo:", + ["Jogo do Dado (Curiosidades)", "Jogo da Forca (Treinamento)", "Caça ao Tesouro (Níveis)"], + ) + + if jogo == "Jogo do Dado (Curiosidades)": + jogo_dado() + elif jogo == "Jogo da Forca (Treinamento)": + jogo_forca_treinamento() + elif jogo == "Caça ao Tesouro (Níveis)": + jogo_tesouro_niveis() + + +if __name__ == "__main__": + main() diff --git a/listar_perguntas.py b/listar_perguntas.py new file mode 100644 index 0000000000000000000000000000000000000000..a72a35be615d80ca038a365b178dda9754e896c3 --- /dev/null +++ b/listar_perguntas.py @@ -0,0 +1,22 @@ +from banco import SessionLocal +from models import QuizPergunta, QuizResposta + +def listar_perguntas_respostas(): + db = SessionLocal() + try: + perguntas = db.query(QuizPergunta).filter(QuizPergunta.ativo == True).all() + for p in perguntas: + print(f"Pergunta (id={p.id}): {p.pergunta}") + respostas = db.query(QuizResposta).filter(QuizResposta.pergunta_id == p.id).all() + if not respostas: + print(" *** SEM RESPOSTAS CADASTRADAS ***") + continue + for r in respostas: + correta = " (correta)" if r.correta else "" + print(f" - Resposta (id={r.id}): {r.texto}{correta}") + print() + finally: + db.close() + +if __name__ == "__main__": + listar_perguntas_respostas() diff --git a/log.py b/log.py new file mode 100644 index 0000000000000000000000000000000000000000..3c55ec06ab25d7550f6916b62bc703f80e81b00b --- /dev/null +++ b/log.py @@ -0,0 +1,63 @@ + +from datetime import datetime +import logging +from banco import SessionLocal +from models import LogAcesso + +# Configuração mínima de logger (opcional; mantenha neutro) +logger = logging.getLogger(__name__) + +def registrar_log(usuario, acao, tabela, registro_id=None): + """ + Registra um log de acesso/ação na tabela LogAcesso. + Mantém assinatura e padrão original, mas com tratamento de exceções e normalização. + + :param usuario: str | usuário responsável pela ação (fallback para 'sistema' se None/vazio) + :param acao: str | ação realizada (ex.: 'CRIAR', 'DESATIVAR', 'EXCLUIR') + :param tabela: str | nome da tabela/entidade relacionada (ex.: 'eventos_calendario') + :param registro_id: int | id do registro afetado (opcional) + """ + # Normalização defensiva (mantendo comportamento atual) + usuario = (usuario or "sistema") + acao = (acao or "").strip() or "DESCONHECIDA" + tabela = (tabela or "").strip() or "desconhecida" + + db = SessionLocal() + try: + log = LogAcesso( + usuario=usuario, + acao=acao, + tabela=tabela, + registro_id=registro_id, + data_hora=datetime.now() + ) + db.add(log) + db.commit() + except Exception as e: + # Em caso de falha, desfazer transação e registrar o problema no logger + try: + db.rollback() + except Exception: + # Se rollback também falhar, seguimos fechando a sessão + pass + logger.exception( + "Falha ao registrar log: usuario=%r, acao=%r, tabela=%r, registro_id=%r. Erro: %s", + usuario, acao, tabela, registro_id, e + ) + # Não propaga exceção: evita quebrar o fluxo da aplicação + finally: + db.close() + + +# ✅ Opcional: uma versão 'safe' que nunca lança erro para o chamador. +# Mantém o padrão existente; use quando o log não pode interromper a operação. +def registrar_log_safe(usuario, acao, tabela, registro_id=None): + try: + registrar_log(usuario, acao, tabela, registro_id) + except Exception: + # Defesa adicional: se algo escapar do registrar_log, não quebra o chamador + logger.exception( + "registrar_log_safe: erro inesperado ao registrar log (usuario=%r, acao=%r, tabela=%r, registro_id=%r)", + usuario, acao, tabela, registro_id + ) + diff --git a/login.py b/login.py new file mode 100644 index 0000000000000000000000000000000000000000..89777a4395116ecbac56f71b842a086a3906a279 --- /dev/null +++ b/login.py @@ -0,0 +1,175 @@ + +# -*- coding: utf-8 -*- +import streamlit as st +from banco import SessionLocal +from models import Usuario +from utils_seguranca import verificar_senha +from utils_auditoria import registrar_log + +# 🔀 Roteador de banco (Produção/Teste/Treinamento) +# Observação: este import assume que db_router.py está na raiz do projeto. +# Se ainda não existir, usamos um fallback suave (default 'prod'). +try: + from db_router import set_db_choice, current_db_choice, list_banks, bank_label + _HAS_ROUTER = True +except Exception: + _HAS_ROUTER = False + def set_db_choice(choice: str): + st.session_state["__db_choice__"] = (choice or "prod").lower() + def current_db_choice() -> str: + return st.session_state.get("__db_choice__", "prod") + def list_banks(): + return ["prod", "test"] # fallback básico + def bank_label(choice: str) -> str: + return {"prod": "Banco 1 (📗 Produção)", "test": "Banco 2 (📕 Teste)"}.get(choice, choice) + + +def _mostrar_efeito_aniversario(nome: str): + """Exibe imediatamente efeito e mensagem central de aniversário.""" + # Efeito visual (confete e balões) + st.snow() + st.balloons() + + # Mensagem centralizada + st.markdown( + f""" +
+
+ 🎉 Feliz Aniversário, {nome}! 🎉 +
+
+ """, + unsafe_allow_html=True + ) + st.caption("Desejamos a você muitas conquistas e bons embarques ao longo do ano! 💜") + + +def login(): + st.subheader("🔐 Login") + + # ✅ Seleção do banco (dinâmica) — armazenado em sessão para toda a navegação + # Mantém experiência simples e clara para o usuário. + banks = list_banks() # ex.: ['prod', 'test', 'treinamento'] + labels = [bank_label(b) for b in banks] # rótulos amigáveis para UI + idx_default = banks.index("prod") if "prod" in banks else 0 + + banco_label = st.selectbox("Usar banco:", labels, index=idx_default) + db_choice = banks[labels.index(banco_label)] + set_db_choice(db_choice) + + # (Opcional) Indicação visual do banco ativo na sidebar + # Usando ícones diferentes para cada ambiente: + ambiente = current_db_choice() + if ambiente == "prod": + badge = "🟢 Produção" + elif ambiente == "test": + badge = "🔴 Teste" + elif ambiente == "treinamento": + badge = "🔵 Treinamento" + else: + badge = ambiente + st.sidebar.caption(f"🗄️ Banco ativo: {badge}") + + # Campos de credencial + usuario_input = st.text_input("Usuário") + senha_input = st.text_input("Senha", type="password") + + # 🔘 Botão de entrada (mantendo seu fluxo) + if st.button("Entrar", type="primary"): + db = SessionLocal() + try: + usuario_db = ( + db.query(Usuario) + .filter( + Usuario.usuario == usuario_input, + Usuario.ativo == True + ) + .first() + ) + + if not usuario_db or not verificar_senha(senha_input, usuario_db.senha): + st.error("❌ Usuário ou senha inválidos.") + + # 📝 Auditoria — registra também o ambiente atual (prod/test/treinamento) + try: + registrar_log( + usuario=usuario_input, + acao="Tentativa de login inválida", + tabela="usuarios", + ambiente=current_db_choice() + ) + except Exception: + # Não quebra o fluxo se auditoria falhar + pass + + return + + # ✅ LOGIN OK + st.session_state.logado = True + st.session_state.usuario = usuario_db.usuario + st.session_state.perfil = usuario_db.perfil + + # ✅ Armazenar e-mail e, se disponível, nome (para exibição na UI) + # Obs.: getattr evita erro caso o campo não exista ou esteja nulo. + st.session_state.email = getattr(usuario_db, "email", None) + st.session_state.nome = getattr(usuario_db, "nome", None) + + # 🔁 IMPORTANTE: força revalidação do quiz + st.session_state.quiz_verificado = False + + # 📝 Auditoria de sucesso — registra o ambiente + try: + registrar_log( + usuario=usuario_db.usuario, + acao="Login realizado com sucesso", + tabela="usuarios", + registro_id=usuario_db.id, + ambiente=current_db_choice() + ) + except Exception: + pass + + # 🎂 NOVO: checagem de data de aniversário (mês/dia), compatível com Date e ISO string + try: + from datetime import date as _date + + def _to_date_safe(val): + if not val: + return None + # já é 'date'? + if isinstance(val, _date): + return val + # tenta converter de string ISO "YYYY-MM-DD" + try: + yy, mm, dd = map(int, str(val).split("-")) + return _date(yy, mm, dd) + except Exception: + return None + + dn = _to_date_safe(getattr(usuario_db, "data_aniversario", None)) + hoje = _date.today() + + if dn and (dn.month == hoje.month and dn.day == hoje.day): + # Mostra efeito imediatamente (antes do rerun) + nome_exibir = st.session_state.get("nome") or st.session_state.get("usuario") or "Usuário" + _mostrar_efeito_aniversario(nome_exibir) + + # Opcional: também sinaliza para main() caso queira reapresentar em outra área + st.session_state["__show_birthday__"] = True + except Exception: + # Não impede o login se algo falhar nessa checagem + pass + + st.success("✅ Login realizado com sucesso!") + st.rerun() + + finally: + db.close() + + + diff --git a/models.py b/models.py new file mode 100644 index 0000000000000000000000000000000000000000..faea3c65cdc404bb8ead8ce7fcb7c5ddbd3938f2 --- /dev/null +++ b/models.py @@ -0,0 +1,463 @@ +# -*- coding: utf-8 -*- +from sqlalchemy import ( + Column, + Integer, + String, + Date, + DateTime, + Boolean, + ForeignKey, + Text +) +from sqlalchemy.orm import relationship + # If you face cyclic import issues, place Base here via declarative_base: + # from sqlalchemy.orm import declarative_base + # Base = declarative_base() +from datetime import datetime +from banco import Base +from sqlalchemy.sql import func # server_default em AvisoGlobal + +# ===================================================== +# TABELA EQUIPAMENTOS +# ===================================================== +class Equipamento(Base): + __tablename__ = "equipamentos" + + id = Column(Integer, primary_key=True, index=True) + + # Identificação + fpso1 = Column(String, index=True, nullable=False) + fpso = Column(String, index=True, nullable=False) + data_coleta = Column(String, index=True, nullable=False) # sugestão futura: Date + + # Responsáveis + especialista = Column(String, nullable=False) + conferente = Column(String, nullable=False) + osm = Column(String, nullable=False) + + # Operacional + modal = Column(String, index=True, nullable=False) + quant_equip = Column(Integer, default=0) + mrob = Column(String, nullable=False) + + # Métricas + linhas_osm = Column(Integer, default=0) + linhas_mrob = Column(Integer, default=0) + linhas_erros = Column(Integer, default=0) + + # Erros + erro_storekeeper = Column(String) + erro_operacao = Column(String) + erro_especialista = Column(String) + erro_outros = Column(String) + + # Dados complementares + inclusao_exclusao = Column(String) + po = Column(String) + part_number = Column(String) + material = Column(String) + + solicitante = Column(String, nullable=False) + motivo = Column(String) + requisitante = Column(String, nullable=False) + nota_fiscal = Column(String, nullable=False) + impacto = Column(String, nullable=False) + dimensao = Column(String, nullable=False) + + observacoes = Column(String) + + # Dia de inclusão + dia_inclusao = Column(String, nullable=True, index=True) + + # Auditoria + data_hora_input = Column(DateTime, default=datetime.utcnow, index=True) + + +# ===================================================== +# TABELA FPSOS +# ===================================================== +class FPSO(Base): + __tablename__ = "fpsos" + + id = Column(Integer, primary_key=True) + nome = Column(String, unique=True, nullable=False, index=True) + ativo = Column(Boolean, default=True) + data_cadastro = Column(DateTime, default=datetime.utcnow) + + +# ===================================================== +# LOG DE AUDITORIA (OFICIAL) +# ===================================================== +class LogAcesso(Base): + __tablename__ = "log_acesso" + + id = Column(Integer, primary_key=True) + + usuario = Column(String, nullable=False, index=True) + acao = Column(String, nullable=False) + tabela = Column(String, nullable=True) + registro_id = Column(Integer, nullable=True) + + data_hora = Column(DateTime, default=datetime.utcnow, index=True) + + +# ===================================================== +# USUÁRIOS +# ===================================================== +class Usuario(Base): + __tablename__ = "usuarios" + + id = Column(Integer, primary_key=True) + + # login & segurança + usuario = Column(String, unique=True, nullable=False, index=True) + senha = Column(String, nullable=False) + + # perfil & status + perfil = Column(String, nullable=False) # admin | usuario | consulta + ativo = Column(Boolean, default=True) + + # auditoria + data_criacao = Column(DateTime, default=datetime.utcnow) + + # UI/contato + nome = Column(String, nullable=True, index=True) + email = Column(String, unique=True, index=True, nullable=True) + + # aniversário + data_aniversario = Column(Date, nullable=True) + + +# ===================================================== +# QUIZ - PERGUNTAS +# ===================================================== +class QuizPergunta(Base): + __tablename__ = "quiz_perguntas" + + id = Column(Integer, primary_key=True) + pergunta = Column(String, nullable=False) + ativo = Column(Boolean, default=True) + data_criacao = Column(DateTime, default=datetime.utcnow) + + respostas = relationship( + "QuizResposta", + back_populates="pergunta", + cascade="all, delete-orphan" + ) + + +# ===================================================== +# QUIZ - RESPOSTAS +# ===================================================== +class QuizResposta(Base): + __tablename__ = "quiz_respostas" + + id = Column(Integer, primary_key=True) + pergunta_id = Column(Integer, ForeignKey("quiz_perguntas.id"), nullable=False) + texto = Column(String, nullable=False) + correta = Column(Boolean, default=False) + + pergunta = relationship("QuizPergunta", back_populates="respostas") + + +# ===================================================== +# QUIZ - PONTUAÇÃO / RANKING +# ===================================================== +class QuizPontuacao(Base): + __tablename__ = "quiz_pontuacoes" + + id = Column(Integer, primary_key=True) + usuario = Column(String, nullable=False, index=True) + pontos = Column(Integer, nullable=False, default=0) + data = Column(DateTime, default=datetime.utcnow, index=True) + + +# ===================================================== +# VÍDEOS - CATEGORIAS +# ===================================================== +class VideoCategoria(Base): + __tablename__ = "video_categorias" + + id = Column(Integer, primary_key=True) + nome = Column(String, nullable=False, unique=True) + ativo = Column(Boolean, default=True) + data_criacao = Column(DateTime, default=datetime.utcnow) + + +# ===================================================== +# VÍDEOS +# ===================================================== +class Video(Base): + __tablename__ = "videos" + + id = Column(Integer, primary_key=True) + titulo = Column(String, nullable=False) + descricao = Column(String) + url = Column(String, nullable=False) + + categoria_id = Column(Integer, ForeignKey("video_categorias.id")) + categoria = relationship("VideoCategoria") + + ativo = Column(Boolean, default=True) + data_criacao = Column(DateTime, default=datetime.utcnow) + + +# ===================================================== +# CALENDÁRIO - EVENTOS / LEMBRETES +# ===================================================== +class EventoCalendario(Base): + __tablename__ = "eventos_calendario" + + id = Column(Integer, primary_key=True) + titulo = Column(String, nullable=False) + descricao = Column(String) + data_evento = Column(Date, nullable=False) + data_lembrete = Column(Date) + ativo = Column(Boolean, default=True) + usuario_criacao = Column(String, nullable=False) + data_criacao = Column(DateTime, default=datetime.utcnow) + + +# ===================================================== +# IOI-RUN - SUGESTÕES DO SISTEMA +# ===================================================== +class IOIRunSugestao(Base): + __tablename__ = "ioirun_sugestao" + + id = Column(Integer, primary_key=True, index=True) + + # Identificação do autor + usuario = Column(String, nullable=False, index=True) + + # Conteúdo + area = Column(String, nullable=True, index=True) + mensagem = Column(Text, nullable=False) + + # Resposta do time (admin) + resposta = Column(Text, nullable=True) + status = Column(String, default="pendente", nullable=False, index=True) # pendente | respondida + responsavel = Column(String, nullable=True) + + # Auditoria + data_envio = Column(DateTime, default=datetime.utcnow, nullable=False, index=True) + data_resposta = Column(DateTime, nullable=True) + + +# ===================================================== +# RNC - REGISTRO DE NÃO CONFORMIDADES (FOR-SGQ-08) +# ===================================================== +class RNC(Base): + __tablename__ = "rnc" + + id = Column(Integer, primary_key=True, index=True) + + # Identificação + codigo = Column(String(20), unique=True, index=True) # ex.: RNC-2026-0001 + titulo = Column(String(200), nullable=False) + descricao = Column(Text, nullable=False) + + # Cabeçalho do formulário + data_form = Column(Date, nullable=True, index=True) # Data do formulário + emitente = Column(String(120), nullable=True, index=True) + rnc_cliente_numero = Column(String(50), nullable=True) + cliente_emitente = Column(String(120), nullable=True) + area_solicitante = Column(String(120), nullable=True, index=True) + area_notificada = Column(String(120), nullable=True, index=True) + origem = Column(String(50), nullable=True, index=True) # Auditoria Interna/Externa/Outras + + # Envolvidos + envolvido1_nome = Column(String(120), nullable=True) + envolvido1_matricula = Column(String(50), nullable=True) + envolvido1_funcao = Column(String(120), nullable=True) + envolvido2_nome = Column(String(120), nullable=True) + envolvido2_matricula = Column(String(50), nullable=True) + envolvido2_funcao = Column(String(120), nullable=True) + + # Classificação + tipo = Column(String(50), nullable=True) + severidade = Column(String(20), nullable=True) + prioridade = Column(String(20), nullable=True) + + # Status e prazos + status = Column(String(30), default="Aberta", nullable=False, index=True) + data_abertura = Column(DateTime, default=datetime.utcnow, index=True) + prazo = Column(DateTime, nullable=True, index=True) + encerrada_em = Column(DateTime, nullable=True, index=True) + + # Responsáveis + responsavel = Column(String(120), nullable=True, index=True) + criado_por = Column(String(120), nullable=False, index=True) + + # Complementares + cliente = Column(String(120), nullable=True) + local = Column(String(120), nullable=True) + + # Análise das causas + metodologia = Column(String(120), nullable=True) # ex.: Ishikawa, 5 Porquês + causa_raiz = Column(Text, nullable=True) # descrição da causa raiz + ishikawa_json = Column(Text, nullable=True) # opcional: armazenar estrutura Ishikawa em JSON + + # Auditoria + data_hora_input = Column(DateTime, default=datetime.utcnow, index=True) + + # Relacionamentos + comentarios = relationship("RNCComentario", back_populates="rnc", cascade="all, delete-orphan") + acoes = relationship("RNCAcaoCorretiva", back_populates="rnc", cascade="all, delete-orphan") + anexos = relationship("RNCAnexo", back_populates="rnc", cascade="all, delete-orphan") + + +# ===================================================== +# RNC - COMENTÁRIOS / TIMELINE +# ===================================================== +class RNCComentario(Base): + __tablename__ = "rnc_comentario" + + id = Column(Integer, primary_key=True, index=True) + rnc_id = Column(Integer, ForeignKey("rnc.id"), nullable=False, index=True) + + data = Column(DateTime, default=datetime.utcnow, index=True) + autor = Column(String(120), nullable=False, index=True) + mensagem = Column(Text, nullable=False) + + status_novo = Column(String(30), nullable=True, index=True) + prazo_novo = Column(DateTime, nullable=True, index=True) + responsavel_novo = Column(String(120), nullable=True, index=True) + + rnc = relationship("RNC", back_populates="comentarios") + + +# ===================================================== +# RNC - AÇÕES CORRETIVAS / PREVENTIVAS +# ===================================================== +class RNCAcaoCorretiva(Base): + __tablename__ = "rnc_acao" + + id = Column(Integer, primary_key=True, index=True) + rnc_id = Column(Integer, ForeignKey("rnc.id"), nullable=False, index=True) + + descricao = Column(Text, nullable=False) + responsavel = Column(String(120), nullable=True, index=True) + prazo = Column(DateTime, nullable=True, index=True) + + status = Column(String(30), default="Planejada", nullable=False, index=True) + eficacia = Column(String(30), nullable=True, index=True) + conclusao_em = Column(DateTime, nullable=True, index=True) + + rnc = relationship("RNC", back_populates="acoes") + + +# ===================================================== +# RNC - ANEXOS +# ===================================================== +class RNCAnexo(Base): + __tablename__ = "rnc_anexo" + + id = Column(Integer, primary_key=True, index=True) + rnc_id = Column(Integer, ForeignKey("rnc.id"), nullable=False, index=True) + + nome_arquivo = Column(String(255), nullable=False) + caminho = Column(String(500), nullable=False) + conteudo_tipo = Column(String(120), nullable=True) + + enviado_por = Column(String(120), nullable=True, index=True) + enviado_em = Column(DateTime, default=datetime.utcnow, index=True) + + rnc = relationship("RNC", back_populates="anexos") + + +# ===================================================== +# AVISO GLOBAL (banner superior) +# ===================================================== +class AvisoGlobal(Base): + __tablename__ = "aviso_global" + + id = Column(Integer, primary_key=True, index=True) + mensagem = Column(Text, nullable=False) + + # Estilo/visual + bg_color = Column(String(32), default="#FFF3CD") + text_color = Column(String(32), default="#664D03") + largura = Column(String(16), default="100%") + efeito = Column(String(16), default="marquee") + velocidade = Column(Integer, default=20) + font_size = Column(Integer, default=14) + + # Controle/estado + ativo = Column(Boolean, default=True, index=True) + + # Auditoria + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +# ===================================================== +# RECEBIMENTO — PLANILHA OFICIAL (UM REGISTRO POR LINHA) +# Alinhado ao layout oficial de 37 colunas +# ===================================================== +class RecebimentoRegistro(Base): + __tablename__ = "recebimento_registros" + + # PK interno + ID da planilha + id = Column(Integer, primary_key=True, autoincrement=True, index=True) + id_planilha = Column(Integer, nullable=True, index=True, unique=True) # ID da planilha, se houver + + # Datas + data = Column(Date, nullable=True) + data_emissao = Column(Date, nullable=True) + + # Horas (texto HH:MM:SS para compatibilidade) + hora_chegada_portaria = Column(String(8), nullable=True) + hora_chegada_ifs = Column(String(8), nullable=True) + hora_saida_ifs_wms = Column(String(8), nullable=True) + hora_liberacao_operacao = Column(String(8), nullable=True) + hora_chegada_operacao = Column(String(8), nullable=True) + hora_saida_operacao = Column(String(8), nullable=True) + hora_retorno_operacao = Column(String(8), nullable=True) + hora_liberacao_motorista = Column(String(8), nullable=True) + + # Dados principais + placa_veiculo = Column(String(50), nullable=True) + transportadora = Column(String(255), nullable=True) + po = Column(String(60), nullable=True) + incoterms = Column(String(30), nullable=True) + qtd_sku = Column(Integer, nullable=True) + nota_fiscal = Column(String(80), nullable=True) + fornecedor = Column(String(255), nullable=True) + + # Bools (SIM/NÃO/N/A) + quimicos = Column(Boolean, nullable=True) + fds = Column(Boolean, nullable=True) + repetro = Column(Boolean, nullable=True) + aprovado = Column(Boolean, nullable=True) + + # Status/Texto + natureza_operacao = Column(String(120), nullable=True) + tipo_operacao = Column(String(120), nullable=True) + barco = Column(String(80), nullable=True) + + # Campo alinhado com a coluna "DIVERGENCIA" do layout oficial + divergencia = Column(String(200), nullable=True) + + ifs = Column(String(120), nullable=True) + wms = Column(String(120), nullable=True) + fotografia = Column(String(255), nullable=True) + entrega = Column(String(120), nullable=True) + projeto = Column(String(120), nullable=True) + good_receipt = Column(String(120), nullable=True) + divergencia_recebimento = Column(String(255), nullable=True) + qualidade = Column(String(120), nullable=True) + divergencia_qualidade = Column(String(255), nullable=True) + observacao = Column(Text, nullable=True) + agendamento = Column(String(120), nullable=True) + responsavel = Column(String(120), nullable=True) + + # Novos campos (opcionais) para colunas adicionais do layout + po_alt = Column(String(60), nullable=True) # mapeia "P.O" (alternativo) + pn = Column(String(120), nullable=True) # mapeia "PN" + lot_batch = Column(String(120), nullable=True) # mapeia "LOT BATCH" + + # Auditoria mínima + created_by = Column(String(150), nullable=True) + updated_by = Column(String(150), nullable=True) + created_at = Column(DateTime, default=datetime.utcnow) + updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow) diff --git a/module_loader.py b/module_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..7e5f5739966f1a09bc244e9e01baf9732800c91f --- /dev/null +++ b/module_loader.py @@ -0,0 +1,68 @@ + +# -*- coding: utf-8 -*- +""" +module_loader.py — Carregador dinâmico de módulos por ambiente (TESTE/Produção) +Permite importar 'operacao', 'consulta', etc. a partir de um base_path escolhido no login. +""" + +import os +import importlib.util +import types +import streamlit as st + +SESSION_ENV_BASE_KEY = "__env_base_path__" +SESSION_MOD_CACHE_KEY = "__env_module_cache__" + +def set_env_base(base_path: str): + """Define o caminho base do ambiente (ex.: pasta de produção ou teste).""" + base_path = os.path.abspath(base_path) + if not os.path.isdir(base_path): + raise RuntimeError(f"Caminho do ambiente inválido: {base_path}") + st.session_state[SESSION_ENV_BASE_KEY] = base_path + # Zera cache ao trocar ambiente + st.session_state[SESSION_MOD_CACHE_KEY] = {} + +def get_env_base() -> str: + base = st.session_state.get(SESSION_ENV_BASE_KEY) + if not base: + # Default: usar a própria pasta onde está o app (Produção) + base = os.path.abspath(os.getcwd()) + st.session_state[SESSION_ENV_BASE_KEY] = base + st.session_state[SESSION_MOD_CACHE_KEY] = {} + return base + +def _mod_cache() -> dict: + cache = st.session_state.get(SESSION_MOD_CACHE_KEY) + if cache is None: + cache = {} + st.session_state[SESSION_MOD_CACHE_KEY] = cache + return cache + +def load_module(module_name: str) -> types.ModuleType: + """ + Carrega um módulo pelo nome (ex.: 'operacao') a partir do base_path atual. + Mantém cache por ambiente/arquivo para evitar reimportações. + """ + base_path = get_env_base() + mod_path = os.path.join(base_path, f"{module_name}.py") + if not os.path.isfile(mod_path): + # fallback: se não achar .py direto, permita subpastas (ex.: envs/test/modulos/operacao.py) + # ajuste conforme sua estrutura real: + mod_path_alt = os.path.join(base_path, "modulos", f"{module_name}.py") + if os.path.isfile(mod_path_alt): + mod_path = mod_path_alt + else: + raise FileNotFoundError(f"Módulo '{module_name}' não encontrado em {base_path}") + + cache_key = f"{base_path}::{module_name}" + cache = _mod_cache() + mod = cache.get(cache_key) + if mod: + return mod + + spec = importlib.util.spec_from_file_location(module_name, mod_path) + mod = importlib.util.module_from_spec(spec) + assert spec and spec.loader, f"Falha ao preparar spec para {mod_path}" + spec.loader.exec_module(mod) + cache[cache_key] = mod + return mod diff --git a/modules_map.py b/modules_map.py new file mode 100644 index 0000000000000000000000000000000000000000..ebd0e0acd082f3611c5c8bb911baaaa9159f0fc2 --- /dev/null +++ b/modules_map.py @@ -0,0 +1,263 @@ + +# modules_map.py +from calendario import main as calendario +from calendario_mensal import main as calendario_mensal + +MODULES = { + # ============================= + # Grupo: Operação Load + # ============================= + "formulario": { + "key": "formulario", + "label": "Formulário", + "descricao": "Cadastro de dados operacionais", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + "consulta": { + "key": "consulta", + "label": "Consulta", + "descricao": "Consulta e exportação de registros", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + "relatorio": { + "key": "relatorio", + "label": "Relatório", + "descricao": "Indicadores e análises", + "perfis": ["admin"], + "grupo": "Operação Load" + }, + "ranking": { + "key": "ranking", + "label": "Ranking", + "descricao": "Classificação do quiz", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + "quiz": { + "key": "quiz", + "label": "Quiz", + "descricao": "Questionário de conhecimentos", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + "quiz_admin": { + "key": "quiz_admin", + "label": "Quiz Admin", + "descricao": "Gestão de perguntas do quiz", + "perfis": ["admin"], + "grupo": "Operação Load" + }, + "videos": { + "key": "videos", + "label": "Vídeos", + "descricao": "Biblioteca de vídeos", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + "usuarios": { + "key": "usuarios", + "label": "Usuários", + "descricao": "Gestão de usuários", + "perfis": ["admin"], + "grupo": "Operação Load" + }, + "administracao": { + "key": "administracao", + "label": "Administração", + "descricao": "Administração do sistema", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + "auditoria": { + "key": "auditoria", + "label": "Auditoria", + "descricao": "Log de ações do sistema", + "perfis": ["admin"], + "grupo": "Operação Load" + }, + "jogos": { + "key": "jogos", + "label": "Jogos", + "descricao": "Mini-games e diversão", + "perfis": ["admin"], + "grupo": "Operação Load" + }, + "calendario": { + "key": "calendario", + "label": "Calendário", + "descricao": "Calendário de eventos", + "perfis": ["admin", "consulta", "usuario"], + "grupo": "Operação Load" + }, + "calendario_mensal": { + "key": "calendario_mensal", + "label": "Calendário Mensal", + "descricao": "Calendário de eventos mensal", + "perfis": ["admin", "consulta", "usuario"], + "grupo": "Operação Load" + }, + "auditoria_cleanup": { + "key": "auditoria_cleanup", + "label": "Limpeza Auditoria", + "descricao": "Exclusão de logs antigos de auditoria", + "perfis": ["admin"], + "grupo": "Operação Load" + }, + "importacao": { + "key": "importacao", + "label": "Importação", + "descricao": "Importação de dados via Excel", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + "db_admin": { + "key": "db_admin", + "label": "Admin DB (Schema)", + "descricao": "Editar/Excluir/Adicionar colunas e tipos de dados", + "perfis": ["admin"], + "grupo": "Operação Load" + }, + "db_monitor": { + "key": "db_monitor", + "label": "Monitor DB", + "descricao": "Estatísticas, ocupação e backup planejado do banco", + "perfis": ["admin"], + "grupo": "Operação Load" + }, + "db_export_import": { + "key": "db_export_import", + "label": "Exportação/Importação DB", + "descricao": "Export/Import de todas as tabelas do banco ativo", + "perfis": ["admin"], + "grupo": "Operação Load" + }, + "produtividade_especialista": { + "key": "produtividade_especialista", + "label": "Produtividade por Especialista", + "descricao": "Relatório de produtividade do especialista", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + "outlook_relatorio": { + "key": "outlook_relatorio", + "label": "Relatório portaria", + "descricao": "Relatório de entrada e saída da portaria - ARM", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + "repositorio_load": { + "key": "repositorio_load", + "label": "Repositório Load", + "descricao": "Upload (Admin) e Consulta/Download (Usuário) de Excel e PDF", + "perfis": ["admin", "usuario"], + "grupo": "Operação Load" + }, + + # ============================= + # Grupo: Backload + # ============================= + "backload_consulta": { + "key": "backload_consulta", + "label": "Consulta Backload", + "descricao": "Consulta de operações Backload", + "perfis": ["admin", "usuario"], + "grupo": "Backload" + }, + + # ============================= + # Grupo: Operação + # ============================= + "operacao": { + "key": "operacao", + "label": "Operação", + "descricao": "Relatórios via API (Mayasuite)", + "perfis": ["admin"], + "grupo": "Operação" + }, + + # ============================= + # Grupo: Indicadores + # ============================= + "indicadores": { + "key": "indicadores", + "label": "Indicadores", + "descricao": "Relatórios de indicadores (KPIs por API)", + "perfis": ["admin"], + "grupo": "BI / Indicadores" + }, + + # ============================= + # Grupo: Terceiros + # ============================= + "terceiros_gestao": { + "key": "terceiros_gestao", + "label": "Gestão Terceiros", + "descricao": "Controle de fornecedores e terceiros", + "perfis": ["admin"], + "grupo": "Terceiros" + }, + + # ============================= + # Grupo: IOI-RUN + # ============================= + "resposta": { + "key": "resposta", + "label": "Resposta de perguntas", + "descricao": "Resposta de perguntas do sistema IOI‑RUN", + "perfis": ["admin"], + "grupo": "Resposta de perguntas" + }, + "sugestoes_ioirun": { + "key": "sugestoes_ioirun", + "label": "Sugestões IOI‑RUN", + "descricao": "Envio e histórico de sugestões do sistema IOI‑RUN", + "perfis": ["admin", "usuario", "consulta"], + "grupo": "Geral" + }, + + # ============================= + # Grupo: Qualidade + # ============================= + "rnc": { + "key": "rnc", + "label": "RNC • Não Conformidades", + "descricao": "Registro e acompanhamento de não conformidades, plano de ação e anexos.", + "perfis": ["admin", "usuario"], + "grupo": "Sistemas de Gestão da Qualidade" + }, + "rnc_listagem": { + "key": "rnc_listagem", + "label": "RNC • Listagem", + "descricao": "Consulta de RNCs com filtros, exportação e expanders", + "perfis": ["admin", "usuario", "consulta"], + "grupo": "Sistemas de Gestão da Qualidade" + }, + "rnc_relatorio": { + "key": "rnc_relatorio", + "label": "RNC • Relatórios", + "descricao": "Painel analítico completo de RNC", + "perfis": ["admin", "usuario", "consulta"], + "grupo": "Sistemas de Gestão da Qualidade" + }, + "repo_rnc": { + "key": "repo_rnc", + "label": "RNC • Repositório", + "descricao": "Upload/Download de documentos e planilhas das RNCs", + "perfis": ["admin", "usuario"], + "grupo": "Sistemas de Gestão da Qualidade" + }, + + # ====================================================== + # RECEBIMENTO — PLANILHA OFICIAL + # ====================================================== + "recebimento": { + "key": "recebimento", + "label": "Recebimento", + "descricao": "Recebimento – Planilha Oficial (importação, edição e controle de registros)", + "perfis": ["admin", "usuario"], + "grupo": "Operação" + }, +} + diff --git a/operacao.py b/operacao.py new file mode 100644 index 0000000000000000000000000000000000000000..4c849508869910681b30fa434e316158149a996f --- /dev/null +++ b/operacao.py @@ -0,0 +1,1564 @@ + +# -*- coding: utf-8 -*- +""" +operacao.py — Módulo Operação (Mayasuite) + +Recursos principais: +- Modo rápido (Consulta leve): apenas 1 página + colunas essenciais na visualização +- Limite de itens por página (via x-filter-limit se suportado pela API) +- Cache de consultas (TTL configurável) + botões para limpar cache +- Botão de cancelar consulta +- Barra de progresso por página + status +- Debounce no submit +- Retentativas para 429/5xx (incl. 502) e timeouts com backoff +- Filtros avançados: Data operação (única), Type(API), Tipo SBM (relacionado ao Depositante), + Endereços/Notas/Categorias com sugestões, Destinatários (multisseleção) + CNPJs múltiplos, + Depositantes (multisseleção) +- Formatação PT-BR: datas DD/MM/AAAA, valores R$, SKU sem zeros à esquerda, oculta colunas vazias +- KPIs dinâmicos por consulta (cards) — Estoque, Endereços, NF Entrada/Saída, etc. +- Oculta navegação de outros módulos ao carregar +""" + +import os +import re +import json +import time +import random +from io import BytesIO +from datetime import datetime +import base64 + +import streamlit as st +import pandas as pd +import requests +from requests.adapters import HTTPAdapter +from urllib3.util.retry import Retry + +# Se existirem, importe utilitários do seu projeto +try: + from utils_permissoes import verificar_permissao +except Exception: + def verificar_permissao(_): + return True + +try: + from utils_auditoria import registrar_log +except Exception: + def registrar_log(**kwargs): + return None + +# ===================================================== +# CONFIG (.env) +# ===================================================== +OP_API_BASE_URL = os.getenv("OP_API_BASE_URL", "https://api.mayasuite.com").rstrip("/") +OP_LOGIN_EMAIL = (os.getenv("OP_LOGIN_EMAIL", "") or "").strip() +OP_LOGIN_PASSWORD = (os.getenv("OP_LOGIN_PASSWORD", "") or "").strip() + +# Timeouts +OP_CONNECT_TIMEOUT = float((os.getenv("OP_CONNECT_TIMEOUT", "10") or "10")) # s +OP_READ_TIMEOUT = float((os.getenv("OP_READ_TIMEOUT", "90") or "90")) # s + +# Token direto (bypass do login) +OP_ACCESS_TOKEN = (os.getenv("OP_ACCESS_TOKEN", "") or "").strip() + +# Diagnóstico / alternativas +OP_LOGIN_EMAIL_ALT = (os.getenv("OP_LOGIN_EMAIL_ALT", "") or "").strip() +OP_LOGIN_PASSWORD_ALT = (os.getenv("OP_LOGIN_PASSWORD_ALT", "") or "").strip() +OP_LOGIN_DEBUG = (os.getenv("OP_LOGIN_DEBUG", "false") or "").strip().lower() == "true" + +# Headers compatíveis +OP_COMPAT_HEADERS = (os.getenv("OP_COMPAT_HEADERS", "true") or "").strip().lower() == "true" + +# Proxy (opcional) +OP_PROXY_HTTP = (os.getenv("OP_PROXY_HTTP", "") or "").strip() +OP_PROXY_HTTPS = (os.getenv("OP_PROXY_HTTPS", "") or "").strip() +PROXIES = {"http": OP_PROXY_HTTP, "https": OP_PROXY_HTTPS} if (OP_PROXY_HTTP or OP_PROXY_HTTPS) else None + +# Rate limit / timeouts +OP_RATE_DELAY_SEC = float((os.getenv("OP_RATE_DELAY_SEC", "0.8") or "0.8")) +OP_MAX_RETRIES_PER_PAGE = int((os.getenv("OP_MAX_RETRIES_PER_PAGE", "3") or "3")) +OP_MAX_PAGES = int((os.getenv("OP_MAX_PAGES", "0") or "0")) +OP_MAX_TIMEOUT_RETRIES = int((os.getenv("OP_MAX_TIMEOUT_RETRIES", "2") or "2")) +OP_TIMEOUT_BACKOFF_BASE = float((os.getenv("OP_TIMEOUT_BACKOFF_BASE", "5") or "5")) + +# Retentativas específicas para 5xx (inclusive 502) +OP_MAX_RETRIES_5XX = int((os.getenv("OP_MAX_RETRIES_5XX", "4") or "4")) +OP_5XX_BACKOFF_BASE = float((os.getenv("OP_5XX_BACKOFF_BASE", "3") or "3")) + +# Tempo de cache (minutos) — configura no .env: CACHE_TTL_MIN=5 +CACHE_TTL_MIN = int((os.getenv("CACHE_TTL_MIN", "5") or "5")) +CACHE_TTL_SEC = CACHE_TTL_MIN * 60 + +# Bases +BASES_MAP = { + "Matriz": "5a926346-15ee-4af4-ba2d-1a71d62d9b51", + "CL": "b0099983-5b44-4650-821a-e352c5c1f10e", + "YARD": "2c506e56-641d-48e2-a330-93fd088526cf", +} + +# Endpoints +ENDPOINTS = { + "Nota Fiscal de Entrada": "/wsreceipt/list", + "Nota Fiscal de Saída": "/wsdispatch/list", + "Endereços": "/address/list", + "Endereços bloqueados": "/address/blocking/list", + "Lista de Pedido": "/cargorelease/list", + "Monitor Sefaz": "/monitor/nfe/list", + "Estoque": "/stock/list", + "Operações": "/operation/list", + "Agendamento": "/yms/scheduling/list", + "Produto": "/product/list", + "Faturamento": "/financial/invoice/list", +} + +# Tipos (API) e Tipos SBM +OPERATION_TYPES = ["ALLOCATION", "CHECK", "DISPATCH", "MOVEMENT", "RECEIVING", "PICK"] +SBM_TYPES = ["SBM - LOAD", "SBM - BACKLOAD", "OUTROS"] + +# Cancelamento +CANCEL_TOKEN_KEY = "__op_cancel__" + +# ✅ NOVO: OAuth2 Client Credentials (se a API suportar) +OAUTH_TOKEN_URL = (os.getenv("OAUTH_TOKEN_URL", "") or "").strip() # ex.: https://api.mayasuite.com/oauth/token +OAUTH_CLIENT_ID = (os.getenv("CLIENT_ID", "") or "").strip() +OAUTH_CLIENT_SECRET = (os.getenv("CLIENT_SECRET", "") or "").strip() +OAUTH_SCOPE = (os.getenv("OAUTH_SCOPE", "") or "").strip() # opcional + +# ===================================================== +# RENAME_MAP — renomeia colunas por endpoint (somente se existirem) +# ===================================================== +RENAME_MAP = { + "/wsreceipt/list": { + "wsreceipt_code": "NF Entrada", + "create_date": "Data Emissão", + "customer_document": "CNPJ_Depositante", + "customer_name": "Depositante", + "product_code": "SKU", + "product_description": "Descrição", + "qty": "Qtde", + "unit_measure_code": "Unidade", + "receipt_unit_value": "Vr. Unitário", + "receipt_value": "Vr. Total", + "lot": "Lote", + "sublot": "SubLote", + "expiration_date": "Validade", + "manufacturing_date": "Fabricação", + "location_id_code": "Endereço", + "last_update_date": "Última Atualização", + "last_update_user": "Usuário Atualização", + "category_description": "Categoria", + "type": "Tipo", + }, + "/wsdispatch/list": { + "wsdispatch_code": "NF Saída", + "issue_date": "Data Emissão", + "recipient_document": "CNPJ_Destinatário", + "recipient_description": "Destinatário", + "product_code": "SKU", + "product_description": "Descrição", + "qty": "Qtde", + "unit_measure_code": "Unidade", + "item_unit_value": "Vr. Unitário", + "item_total_value": "Vr. Total", + "lot": "Lote", + "sublot": "SubLote", + "location_code": "Endereço", + "category_description": "Categoria", + "group_description": "Grupo", + "type": "Tipo", + }, + "/address/list": { + "location_code": "Endereço", + "location_id_code": "Endereço", + "description": "Descrição", + "status": "Status", + "fpso": "FPSO", + "last_update_date": "Última Atualização", + "last_update_user": "Usuário Atualização", + "type": "Tipo", + }, + "/address/blocking/list": { + "location_code": "Endereço", + "location_id_code": "Endereço", + "block_reason": "Motivo Bloqueio", + "block_date": "Data Bloqueio", + "block_user": "Usuário Bloqueio", + "unblock_date": "Data Desbloqueio", + "unblock_user": "Usuário Desbloqueio", + "status": "Status", + "type": "Tipo", + }, + "/cargorelease/list": { + "cargorelease_code": "Pedido", + "create_date": "Data Criação", + "customer_document": "CNPJ_Depositante", + "customer_name": "Depositante", + "product_code": "SKU", + "product_description": "Descrição", + "qty": "Qtde", + "unit_measure_code": "Unidade", + "status": "Status", + "expiration_date": "Validade", + "manufacturing_date": "Fabricação", + "category_description": "Categoria", + "type": "Tipo", + }, + "/monitor/nfe/list": { + "nfe_key": "Chave NFe", + "nfe_number": "Número NFe", + "status": "Status SEFAZ", + "protocol": "Protocolo", + "issue_date": "Data Emissão", + "recipient_document": "CNPJ_Destinatário", + "customer_document": "CNPJ_Depositante", + "message": "Mensagem", + "last_update_date": "Última Atualização", + "type": "Tipo", + }, + "/stock/list": { + "date": "Data Operação", + "wsreceipt_code": "Nota Fiscal", + "product_code": "SKU", + "product_description": "Descrição", + "unit_measure_code": "Unidade", + "qty": "Qtde", + "item_unit_value": "Vr. Unitário", + "item_total_value": "Vr. Total", + "customer_document": "CNPJ_Depositante", + "customer_description": "Depositante", + "lot": "Lote", + "location_code": "Endereço", + "expiration_date": "Validade", + "manufacturing_date": "Fabricação", + "category_description": "Categoria", + "group_description": "Grupo", + "recipient_document": "CNPJ_Destinatário", + "recipient_description": "Destinatário", + "qty_reservation": "Qtde Reservada", + "type": "Tipo", + }, + "/operation/list": { + "cargorelease_code": "Pedido", + "create_date": "Data", + "create_user": "Usuário Criação", + "customer_document": "CNPJ_Depositante", + "customer_name": "Depositante", + "expiration_date": "Validade", + "last_update_date": "Última Atualização", + "last_update_user": "Usuário Atualização", + "location_id_code": "Endereço", + "lot": "Lote", + "manufacturing_date": "Fabricação", + "product_code": "SKU", + "product_description": "Descrição", + "qty": "Qtde", + "receipt_unit_value": "Vr. Unitário", + "receipt_value": "Vr. Total", + "sublot": "SubLote", + "type": "Tipo", + "wsreceipt_code": "NF Entrada", + "category_description": "Categoria", + }, + "/yms/scheduling/list": { + "scheduling_id": "ID Agendamento", + "yard": "Pátio", + "dock": "Doca", + "truck_plate": "Placa", + "driver_name": "Motorista", + "scheduled_date": "Data Agendada", + "scheduled_time": "Hora Agendada", + "status": "Status", + "last_update_date": "Última Atualização", + "type": "Tipo", + }, + "/product/list": { + "product_code": "SKU", + "product_description": "Descrição", + "category_description": "Categoria", + "group_description": "Grupo", + "unit_measure_code": "Unidade", + "status": "Status", + "last_update_date": "Última Atualização", + "type": "Tipo", + }, + "/financial/invoice/list": { + "invoice_number": "Número Fatura", + "invoice_date": "Data Fatura", + "customer_document": "CNPJ_Depositante", + "customer_name": "Depositante", + "total_value": "Vr. Total", + "status": "Status", + "nfe_key": "Chave NFe", + "wsdispatch_code": "NF Saída", + "last_update_date": "Última Atualização", + "type": "Tipo", + }, +} + +# ===================================================== +# SESSÃO HTTP (retry 5xx/429) + PROGRESSO + CANCELAMENTO +# ===================================================== +def _build_retry_adapter() -> HTTPAdapter: + """Adapter de retry: lida com 429/5xx transientes.""" + retry = Retry( + total=3, connect=3, read=3, backoff_factor=1.0, + status_forcelist=[429, 500, 502, 503, 504], + allowed_methods=["POST"], raise_on_status=False, + ) + return HTTPAdapter(max_retries=retry) + +def _get_session() -> requests.Session: + sess = st.session_state.get("_op_session") + if sess is None: + sess = requests.Session() + adapter = _build_retry_adapter() + sess.mount("https://", adapter) + sess.mount("http://", adapter) + st.session_state["_op_session"] = sess + return sess + +# ===================================================== +# LOGIN / TOKEN +# ===================================================== +class TokenManager: + """ + Gerencia token de acesso: + - Se OP_ACCESS_TOKEN estiver definido no .env: usa diretamente (bypass). + - Se variáveis OAuth2 estiverem configuradas: obtém/renova via client_credentials. + - Caso contrário: usa login atual (POST /login) com OP_LOGIN_EMAIL/OP_LOGIN_PASSWORD + (inclui conta alternativa e cooldown). + """ + def __init__(self): + self.access_token: str | None = OP_ACCESS_TOKEN if OP_ACCESS_TOKEN else None + self.expire_ts: float = 0.0 + self._skew_sec: int = 30 # tolerância de relógio + + def _fetch_oauth_token(self) -> tuple[str | None, float]: + """Tenta obter via OAuth2 client_credentials. Retorna (token, expire_ts_epoch).""" + if not (OAUTH_TOKEN_URL and OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET): + return None, 0.0 + try: + resp = requests.post( + OAUTH_TOKEN_URL, + data={"grant_type": "client_credentials", "scope": OAUTH_SCOPE}, + auth=(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET), + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), + proxies=PROXIES + ) + if resp.status_code >= 400: + if OP_LOGIN_DEBUG: + st.warning(f"OAuth2 falhou ({resp.status_code}): {(resp.text or '')[:300]}") + return None, 0.0 + data = resp.json() + token = data.get("access_token") or data.get("token") + expires_in = float(data.get("expires_in") or 3600.0) + return token, (time.time() + max(60.0, expires_in)) + except requests.exceptions.RequestException as e: + if OP_LOGIN_DEBUG: + st.warning(f"Falha OAuth2: {e}") + return None, 0.0 + + def _fetch_login_token(self) -> tuple[str | None, float]: + """ + Fallback para o login atual (POST /login), mantendo: + - alternativa (OP_LOGIN_EMAIL_ALT/OP_LOGIN_PASSWORD_ALT) + - cooldown por banimento (api_login_lock_until) + - e toda lógica de parsing/erros + """ + lock_until = st.session_state.get("api_login_lock_until") + if lock_until and time.time() < lock_until: + raise RuntimeError("Login temporariamente bloqueado após 403 (cooldown ativo).") + + url = f"{OP_API_BASE_URL}/login" + payload_str = json.dumps({"login": OP_LOGIN_EMAIL, "password": OP_LOGIN_PASSWORD}, ensure_ascii=False) + headers = {"Content-Type": "application/json"} + + try: + resp = requests.post(url, headers=headers, data=payload_str, + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES) + except requests.exceptions.RequestException as e: + raise RuntimeError(f"Falha na conexão ao login: {e}") + + body_text = (resp.text or "")[:600] + if resp.status_code >= 400: + if "banned" in body_text.lower(): + st.session_state["api_login_lock_until"] = time.time() + 3600 + if OP_LOGIN_DEBUG: + st.warning(f"Servidor retornou banimento. Cooldown 1h. Corpo: {body_text}") + raise RuntimeError("Origem/host/IP banido pelo servidor (403). Solicite desbloqueio/allowlist.") + if OP_LOGIN_EMAIL_ALT and OP_LOGIN_PASSWORD_ALT: + payload_alt = json.dumps({"login": OP_LOGIN_EMAIL_ALT, "password": OP_LOGIN_PASSWORD_ALT}, ensure_ascii=False) + resp2 = requests.post(url, headers=headers, data=payload_alt, + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES) + if resp2.status_code >= 400: + if OP_LOGIN_DEBUG: + st.warning(f"Alternativa falhou (HTTP {resp2.status_code}). Corpo: {(resp2.text or '')[:600]}") + raise RuntimeError(f"Login rejeitado ({resp.status_code}).") + try: + data2 = resp2.json() + except Exception: + raise RuntimeError("Resposta de login (alt) não é JSON.") + token2 = data2.get("access_token") or data2.get("token") + if not token2: + raise RuntimeError("Token (alt) não encontrado na resposta de login.") + return token2, (time.time() + 3600.0) + if OP_LOGIN_DEBUG: + st.warning(f"Login falhou (HTTP {resp.status_code}). Corpo: {body_text}") + raise RuntimeError(f"Login rejeitado ({resp.status_code}).") + + try: + data = resp.json() + except Exception: + raise RuntimeError("Resposta de login não é JSON.") + token = data.get("access_token") or data.get("token") or (data if isinstance(data, str) else "") + if not token: + raise RuntimeError("Token 'access_token' não encontrado na resposta de login.") + return token, (time.time() + 3600.0) + + def get_token(self) -> str: + """Obtém token atual (renova se necessário).""" + # Bypass: token fixo do .env + if OP_ACCESS_TOKEN: + self.access_token = OP_ACCESS_TOKEN + self.expire_ts = time.time() + 365*24*3600 # validade simbólica longa + return self.access_token + + now = time.time() + if (not self.access_token) or (now + self._skew_sec >= self.expire_ts): + # Tenta primeiro OAuth2; se indisponível, usa login /login + token, exp_ts = self._fetch_oauth_token() + if not token: + token, exp_ts = self._fetch_login_token() + self.access_token = token + self.expire_ts = exp_ts or (now + 3600.0) + return self.access_token + + def force_refresh(self) -> str: + """Força renovar o token (útil em 401) e retorna o novo.""" + self.access_token = None + self.expire_ts = 0.0 + return self.get_token() + +# ✅ Instância global (mantém sua organização) +TM = TokenManager() + +# 🛠️ Refatora função _get_token para delegar ao TokenManager +# (remove cache para não “congelar” o token; mantém assinatura/uso) +def _get_token(login_email: str, login_password: str) -> str: + """ + Obtém token automaticamente: + - OAuth2 client_credentials se configurado; + - Caso contrário, login /login com e-mail/senha (mantendo sua lógica). + """ + return TM.get_token() + +# ===================================================== +# HEADERS +# ===================================================== +def _auth_headers(token: str, base_guid: str, page: int, limit: int, is_post: bool) -> dict: + h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "x-user-enterprise-id": base_guid} + if OP_COMPAT_HEADERS: + h["Accept"] = "application/json" + h["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ArmLoadApp/1.0 PowerQueryCompat" + h["Connection"] = "keep-alive" + h["Origin"] = OP_API_BASE_URL + h["Referer"] = OP_API_BASE_URL + "/" + # Paginação do servidor (se suportado) + h["x-filter-page"] = str(page) + h["x-filter-limit"] = str(limit) + return h + +# ===================================================== +# RATE LIMIT: helpers +# ===================================================== +def _parse_retry_after(resp) -> float: + ra_hdr = resp.headers.get("Retry-After") + if ra_hdr: + try: + return float(ra_hdr.strip()) + except Exception: + pass + try: + data = resp.json() + ra_body = data.get("retry-after") or "" + if isinstance(ra_body, (int, float)): + return float(ra_body) + if isinstance(ra_body, str): + m = re.search(r"(\d+)", ra_body) + if m: + return float(m.group(1)) + except Exception: + pass + return 60.0 + +# ===================================================== +# LIST — POST + RAW JSON com rate-limit/timeout/5xx +# + Progresso de carregamento + Cancelamento +# ===================================================== +def _call_list_paginated( + path: str, + base_guid: str, + token: str, + body_filter: dict, + limit: int = 1000, + max_pages_override: int | None = None +): + """ + - POST com RAW JSON (data=payload_str), como Excel. + - Respeita 429 (Retry-After); aplica atraso entre páginas. + - Retenta automaticamente Timeout (backoff exponencial). + - Retenta 5xx (incl. 502) com backoff exponencial + jitter. + - Suporta cancelamento via st.session_state[CANCEL_TOKEN_KEY]. + - Exibe barra de progresso e status de carregamento por página. + """ + page = 1 + all_rows = [] + session = _get_session() + consecutive_429 = 0 + max_pages = OP_MAX_PAGES if max_pages_override is None else max_pages_override + + # ----- UI de progresso ----- + progress_bar = st.session_state.get("__op_progress_bar__") + status_text = st.session_state.get("__op_status_text__") + if progress_bar is None: + progress_bar = st.progress(0) + st.session_state["__op_progress_bar__"] = progress_bar + if status_text is None: + status_text = st.empty() + st.session_state["__op_status_text__"] = status_text + + denom = max_pages if (max_pages and max_pages > 0) else None + + # reset progresso/cancelamento + st.session_state[CANCEL_TOKEN_KEY] = st.session_state.get(CANCEL_TOKEN_KEY, False) + st.session_state["op_pages_processed"] = 0 + + try: + while True: + # Cancelamento + if st.session_state.get(CANCEL_TOKEN_KEY): + raise RuntimeError("Consulta cancelada.") + + if max_pages and page > max_pages: + break + + url = f"{OP_API_BASE_URL}{path}" + headers = _auth_headers(token, base_guid, page, limit, is_post=True) + payload_str = json.dumps(body_filter or {}, ensure_ascii=False) + + # ---- Retentativas por Timeout ---- + timeout_attempt = 0 + while True: + try: + resp = session.post( + url, headers=headers, data=payload_str, + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES + ) + break + except (requests.exceptions.ReadTimeout, + requests.exceptions.ConnectTimeout, + requests.exceptions.Timeout) as e: + timeout_attempt += 1 + if timeout_attempt > OP_MAX_TIMEOUT_RETRIES: + raise RuntimeError(f"Timeout após {OP_MAX_TIMEOUT_RETRIES} tentativas na página {page}: {e}") + sleep_sec = OP_TIMEOUT_BACKOFF_BASE * timeout_attempt + if OP_LOGIN_DEBUG: + st.warning(f"Timeout na página {page}. Retentativa {timeout_attempt} em ~{sleep_sec}s...") + time.sleep(sleep_sec) + except requests.exceptions.RequestException as e: + raise RuntimeError(f"Falha na página {page}: {e}") + + # ✅ 401 → renovar token 1x (OAuth2/client_credentials ou /login) e re-tentar + if resp.status_code == 401: + token = TM.force_refresh() + headers = _auth_headers(token, base_guid, page, limit, is_post=True) + resp = session.post( + url, headers=headers, data=payload_str, + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES + ) + if resp.status_code == 401: + raise RuntimeError(f"Token inválido/expirado após renovação (401) na página {page}: {resp.text[:500]}") + + # 429 → Retry-After + repetir + if resp.status_code == 429: + wait_sec = _parse_retry_after(resp) + consecutive_429 += 1 + if consecutive_429 > OP_MAX_RETRIES_PER_PAGE: + raise RuntimeError( + f"Limite de tentativas após 429 excedido na página {page}. Aguarde ~{wait_sec}s e tente novamente." + ) + if OP_LOGIN_DEBUG: + st.warning(f"429 recebido. Aguardando ~{wait_sec}s antes de repetir página {page}...") + time.sleep(wait_sec) + resp = session.post( + url, headers=headers, data=payload_str, + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES + ) + + # 5xx (inclui 502 Bad Gateway) → backoff + jitter + if resp.status_code in (500, 502, 503, 504): + for attempt in range(1, OP_MAX_RETRIES_5XX + 1): + wait = OP_5XX_BACKOFF_BASE * (2 ** (attempt - 1)) + random.uniform(0, OP_5XX_BACKOFF_BASE) + if OP_LOGIN_DEBUG: + st.warning(f"{resp.status_code} recebido. Retentativa {attempt}/{OP_MAX_RETRIES_5XX} em ~{wait:.1f}s...") + time.sleep(wait) + try: + resp = session.post( + url, headers=headers, data=payload_str, + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES + ) + except requests.exceptions.RequestException: + continue + if resp.status_code < 500: + break + if resp.status_code in (500, 502, 503, 504): + snippet = (resp.text or "")[:500] + raise RuntimeError(f"Erro {resp.status_code} na página {page}: {snippet}") + + # Outros erros HTTP + if resp.status_code >= 400: + raise RuntimeError(f"Erro {resp.status_code} na página {page}: {resp.text[:500]}") + + # RESET 429 quando OK + consecutive_429 = 0 + + # Parse JSON + try: + data = resp.json() + except Exception: + raise RuntimeError(f"JSON inválido na página {page}: {resp.text[:300]}") + + # Normaliza linhas + if isinstance(data, list): + rows = data + elif isinstance(data, dict): + rows = data.get("data") or data.get("items") or [] + if not isinstance(rows, list): + rows = [] + else: + rows = [] + + # Fim se vazio + if not rows: + break + + # Soma acumulado + all_rows.extend(rows) + st.session_state["op_pages_processed"] = page + + # ----- Atualiza UI de progresso ----- + status_text.info(f"🔄 Carregando página {page}…") + if denom: + frac = min(page / float(denom), 1.0) + progress_bar.progress(frac) + else: + progress_bar.progress((page % 10) / 10.0) + + # Próxima página + page += 1 + + # Atraso entre páginas (rate limit) + if OP_RATE_DELAY_SEC > 0: + time.sleep(OP_RATE_DELAY_SEC) + + # Concluído: barra 100% + progress_bar.progress(1.0) + time.sleep(0.1) + + finally: + # Limpa componentes de UI sempre + try: + status_text.empty() + except Exception: + pass + try: + progress_bar.empty() + except Exception: + pass + st.session_state.pop("__op_progress_bar__", None) + st.session_state.pop("__op_status_text__", None) + + return all_rows + +# ===================================================== +# EXPORTS +# ===================================================== +def _export_excel(df: pd.DataFrame, report_key: str, filtros_aplicados: dict) -> bytes: + buffer = BytesIO() + with pd.ExcelWriter(buffer, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name=report_key[:30] or "Relatorio") + meta_df = pd.DataFrame([filtros_aplicados]) + meta_df.to_excel(writer, index=False, sheet_name="Filtros_Aplicados") + return buffer.getvalue() + +def _export_csv(df: pd.DataFrame) -> bytes: + return df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig") + +# ===================================================== +# Helpers — JWT iat/exp +# ===================================================== +def _jwt_payload(token: str): + try: + parts = token.split(".") + if len(parts) != 3: + return None + def b64url_to_b64(s): return s + "=" * (-len(s) % 4) + payload_b64 = b64url_to_b64(parts[1]) + payload_json = base64.urlsafe_b64decode(payload_b64.encode("utf-8")).decode("utf-8") + return json.loads(payload_json) + except Exception: + return None + +def _fmt_ts(ts): + try: + dt = datetime.utcfromtimestamp(int(ts)) + return dt.strftime("%d/%m/%Y %H:%M:%S") + " UTC" + except Exception: + return "—" + +# ===================================================== +# Sugestões — cache (TTL configurável) +# ===================================================== +@st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False) +def _load_suggestions_recipients(base_guid: str, token: str): + """Sugestões de Destinatários (desc+cnpj) a partir de /stock/list (1 página).""" + session = _get_session() + url = f"{OP_API_BASE_URL}/stock/list" + headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True) + try: + resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False), + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES) + if resp.status_code >= 400: return [] + data = resp.json() + except Exception: + return [] + rows = data if isinstance(data, list) else data.get("data") or data.get("items") or [] + opts, seen = [], set() + for r in rows: + desc = str(r.get("recipient_description") or "").strip() + doc = str(r.get("recipient_document") or "").strip() + if not desc and not doc: continue + label = f"{desc} ({doc})" if doc else desc + key = (label, doc) + if key not in seen: + seen.add(key); opts.append((label, doc)) + return opts + +@st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False) +def _load_suggestions_notas(base_guid: str, token: str, path: str): + """Sugestões de Nota Fiscal a partir do endpoint corrente (1 página).""" + session = _get_session() + url = f"{OP_API_BASE_URL}{path}" + headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True) + try: + resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False), + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES) + if resp.status_code >= 400: return [] + data = resp.json() + except Exception: + return [] + rows = data if isinstance(data, list) else data.get("data") or data.get("items") or [] + field = "wsreceipt_code" if path in ("/wsreceipt/list", "/stock/list", "/operation/list") else \ + "wsdispatch_code" if path in ("/wsdispatch/list", "/financial/invoice/list") else None + opts, seen = [], set() + for r in rows: + nota = str(r.get(field) or "").strip() if field else "" + if nota and nota not in seen: + seen.add(nota); opts.append(nota) + return opts + +@st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False) +def _load_suggestions_categories(base_guid: str, token: str, path: str): + """Sugestões de Categoria (1 página).""" + session = _get_session() + url = f"{OP_API_BASE_URL}{path}" + headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True) + try: + resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False), + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES) + if resp.status_code >= 400: return [] + data = resp.json() + except Exception: + return [] + rows = data if isinstance(data, list) else data.get("data") or data.get("items") or [] + seen, opts = set(), [] + for r in rows: + cat = str(r.get("category_description") or r.get("Categoria") or "").strip() + if cat and cat not in seen: + seen.add(cat); opts.append(cat) + return opts + +@st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False) +def _load_suggestions_addresses(base_guid: str, token: str): + """Sugestões de Endereço (1 página).""" + session = _get_session() + url = f"{OP_API_BASE_URL}/address/list" + headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True) + try: + resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False), + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES) + if resp.status_code >= 400: return [] + data = resp.json() + except Exception: + return [] + rows = data if isinstance(data, list) else data.get("data") or data.get("items") or [] + seen, opts = set(), [] + for r in rows: + code = str(r.get("location_code") or r.get("location_id_code") or "").strip() + desc = str(r.get("description") or "").strip() + if not code: continue + label = f"{code} - {desc}" if desc else code + if code not in seen: + seen.add(code); opts.append((label, code)) + return opts + +@st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False) +def _load_suggestions_depositantes(base_guid: str, token: str): + """Sugestões de Depositantes (nome+cnpj) a partir de /stock/list (1 página).""" + session = _get_session() + url = f"{OP_API_BASE_URL}/stock/list" + headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True) + try: + resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False), + timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES) + if resp.status_code >= 400: return [] + data = resp.json() + except Exception: + return [] + rows = data if isinstance(data, list) else data.get("data") or data.get("items") or [] + opts, seen = [], set() + for r in rows: + name = str(r.get("customer_name") or r.get("customer_description") or r.get("Depositante") or "").strip() + doc = str(r.get("customer_document") or r.get("CNPJ_Depositante") or "").strip() + if not name and not doc: continue + label = f"{name} ({doc})" if doc else name + key = (label, doc) + if key not in seen: + seen.add(key); opts.append((label, doc)) + return opts + +# ===================================================== +# Body mapping (filtros comuns) — server-side quando suportado +# ===================================================== +def _map_common_filters_to_body(path: str, ui: dict) -> dict: + """ + Para múltiplas seleções, a maioria dos endpoints não aceita array. + Estratégia: enviar 1 valor (o primeiro) no body e aplicar os demais client-side. + """ + enderecos_sel = ui.get("enderecos_sel", []) + endereco_text = ui.get("endereco_text", "").strip() + nf_sel = ui.get("nota_fiscal_sel", "").strip() + sku_item = ui.get("sku_item", "").strip() + part_number = ui.get("part_number", "").strip() + destinatarios_docs = ui.get("destinatarios_docs", []) + cnpjs_livres = ui.get("cnpjs_livres", []) + data_op = ui.get("data_op") # date único + categorias_sel = ui.get("categorias_sel", []) + types_sel = ui.get("types_sel", []) + depositantes_docs = ui.get("depositantes_docs", []) # múltiplos CNPJs (sugestões) + + mapped = {} + + # Endereço + addr = (enderecos_sel[0] if enderecos_sel else "") or endereco_text + if addr: + mapped["location_code"] = addr + + # Nota fiscal (apenas sugestões) + nf_val = nf_sel + if nf_val: + if path in ("/wsreceipt/list", "/stock/list", "/operation/list"): + mapped["wsreceipt_code"] = nf_val + if path in ("/wsdispatch/list", "/financial/invoice/list"): + mapped["wsdispatch_code"] = nf_val + + # SKU / Part Number + if sku_item: + mapped["product_code"] = sku_item + if part_number: + mapped["part_number"] = part_number + + # Categoria (1 server-side; demais client-side) + if len(categorias_sel) == 1: + mapped["category_description"] = categorias_sel[0] + + # Destinatário CNPJ (1 server-side) + all_cnpjs_dest = [c for c in destinatarios_docs + cnpjs_livres if c] + if all_cnpjs_dest: + mapped["recipient_document"] = all_cnpjs_dest[0] + + # Depositante CNPJ (1 server-side) + if depositantes_docs: + mapped["customer_document"] = depositantes_docs[0] + + # Data operação — em /operation/list + if path == "/operation/list" and data_op: + mapped["date_ini"] = data_op.strftime("%Y-%m-%d") + mapped["date_fim"] = data_op.strftime("%Y-%m-%d") + + # Type(API) — 1 server-side + if path == "/operation/list" and len(types_sel) == 1: + mapped["type"] = types_sel[0] + + return mapped + +# ===================================================== +# Client-side filters (Data operação única, Categoria, múltiplos seleção) +# ===================================================== +def _contains_ci(series: pd.Series, needle: str) -> pd.Series: + if series.dtype != "O": + series = series.astype("string") + return series.fillna("").str.contains(needle, case=False, na=False) + +def _coalesce_datetime_ptbr(df: pd.DataFrame, candidates: list) -> pd.Series: + result = pd.Series([pd.NaT] * len(df), index=df.index) + for c in candidates: + if c in df.columns: + s = df[c] + s1 = pd.to_datetime(s, errors="coerce", dayfirst=True) + result = result.fillna(s1) + mask_nat = result.isna() + if mask_nat.any(): + s2 = pd.to_datetime(s[mask_nat], errors="coerce", dayfirst=False) + result.loc[mask_nat] = s2 + return result + +def _classify_sbm(row: pd.Series) -> str: + """ + Classificação SBM baseada em DEPOSITANTE (não em Destinatário): + - Se Depositante contém 'SBM' e Tipo == RECEIVING -> SBM - LOAD + - Se Depositante contém 'SBM' e Tipo == DISPATCH -> SBM - BACKLOAD + - Caso contrário -> OUTROS + """ + depos = str(row.get("Depositante") or row.get("customer_name") or "").upper() + typ = str(row.get("Tipo") or row.get("type") or "").upper() + if "SBM" in depos: + if typ == "RECEIVING": return "SBM - LOAD" + if typ == "DISPATCH": return "SBM - BACKLOAD" + return "OUTROS" + +def _apply_client_side_filters(df: pd.DataFrame, ui: dict, sbm_types_sel: list, types_sel: list) -> pd.DataFrame: + enderecos_sel = ui.get("enderecos_sel", []) + endereco_text = ui.get("endereco_text", "").strip() + nf_sel = ui.get("nota_fiscal_sel", "").strip() + sku_item = ui.get("sku_item", "").strip() + part_number = ui.get("part_number", "").strip() + destinatarios_docs = [c.strip() for c in ui.get("destinatarios_docs", []) if c.strip()] + cnpjs_livres = [c.strip() for c in ui.get("cnpjs_livres", []) if c.strip()] + data_op = ui.get("data_op") # único + categorias_sel = ui.get("categorias_sel", []) + depositantes_docs = [c.strip() for c in ui.get("depositantes_docs", []) if c.strip()] + depositantes_nomes = [s.strip() for s in ui.get("depositantes_nomes", []) if str(s).strip()] + + # Endereços + addr_cols = [c for c in ["Endereço", "location_code", "location_id_code"] if c in df.columns] + if addr_cols: + if enderecos_sel: + mask = False + for c in addr_cols: + mask = df[c].isin(enderecos_sel) | mask + df = df[mask].copy() + if endereco_text: + mask = False + for c in addr_cols: + mask = _contains_ci(df[c], endereco_text) | mask + df = df[mask].copy() + + # Nota Fiscal (apenas seleção) + if nf_sel: + cols = [c for c in ["Nota Fiscal", "NF Saída", "wsreceipt_code", "wsdispatch_code"] if c in df.columns] + mask = False + for c in cols: + mask = _contains_ci(df[c], nf_sel) | mask + df = df[mask].copy() + + # SKU / item + if sku_item: + cols = [c for c in ["SKU", "product_code"] if c in df.columns] + mask = False + for c in cols: + mask = _contains_ci(df[c], sku_item) | mask + df = df[mask].copy() + + # Part Number + if part_number and "part_number" in df.columns: + df = df[_contains_ci(df["part_number"], part_number)].copy() + + # CNPJ Destinatários + all_cnpjs_dest = destinatarios_docs + cnpjs_livres + if all_cnpjs_dest: + cols = [c for c in ["CNPJ_Destinatário", "recipient_document"] if c in df.columns] + mask = False + for c in cols: + mask = df[c].isin(all_cnpjs_dest) | mask + df = df[mask].copy() + + # Depositantes (por CNPJ e/ou por nome) — multisseleção + dep_doc_cols = [c for c in ["CNPJ_Depositante", "customer_document"] if c in df.columns] + dep_name_cols = [c for c in ["Depositante", "customer_name", "customer_description"] if c in df.columns] + if depositantes_docs and dep_doc_cols: + mask = False + for c in dep_doc_cols: + mask = df[c].isin(depositantes_docs) | mask + df = df[mask].copy() + if depositantes_nomes and dep_name_cols: + mask = False + for c in dep_name_cols: + mask_local = False + for name in depositantes_nomes: + mask_local = _contains_ci(df[c], name) | mask_local + mask = mask | mask_local + df = df[mask].copy() + + # Data da operação (única) + if data_op: + op_date = _coalesce_datetime_ptbr(df, [ + "Data Operação","Data","Data Emissão","Data Agendada", + "Validade","Fabricação", + "date","create_date","issue_date","scheduled_date","expiration_date","manufacturing_date","last_update_date" + ]) + data_ini = pd.to_datetime(data_op) + data_fim = data_ini + pd.Timedelta(days=1) - pd.Timedelta(milliseconds=1) + df = df[(op_date >= data_ini) & (op_date <= data_fim)].copy() + + # Categoria + if categorias_sel: + cols = [c for c in ["Categoria", "category_description"] if c in df.columns] + mask = False + for c in cols: + mask = df[c].isin(categorias_sel) | mask + df = df[mask].copy() + + # Tipo (API) multisseleção ('Tipo' ou 'type') + if types_sel and ("Tipo" in df.columns or "type" in df.columns): + col_t = "Tipo" if "Tipo" in df.columns else "type" + df = df[df[col_t].isin(types_sel)].copy() + + # Tipo de operação (SBM) — baseado em DEPOSITANTE + if sbm_types_sel: + sbm_series = df.apply(_classify_sbm, axis=1) + df = df[sbm_series.isin(sbm_types_sel)].copy() + + return df + +# ===================================================== +# Formatação (R$, SKU, datas PT-BR, remove colunas vazias) +# ===================================================== +def _format_currency_brl(val): + try: + x = float(val) + except Exception: + return val + s = f"{x:,.2f}" + s = s.replace(",", "X").replace(".", ",").replace("X", ".") + return f"R$ {s}" + +def _format_dates_ptbr(df: pd.DataFrame) -> pd.DataFrame: + candidate_cols = [ + "Data Operação","Data","Data Emissão","Validade","Fabricação", + "Data Agendada","date","create_date","issue_date","expiration_date", + "manufacturing_date","scheduled_date","last_update_date" + ] + for col in df.columns: + if col in candidate_cols: + try: + s = pd.to_datetime(df[col], errors="coerce", dayfirst=True) + nat_mask = s.isna() + if nat_mask.any(): + s.loc[nat_mask] = pd.to_datetime(df[col][nat_mask], errors="coerce", dayfirst=False) + df[col] = s.dt.strftime("%d/%m/%Y").fillna(df[col]) + except Exception: + pass + return df + +def _format_dataframe(df: pd.DataFrame) -> pd.DataFrame: + if df.empty: + return df + + # R$ + money_candidates = ["Vr. Total", "Vr. Unitário", "Item_Value", + "item_total_value", "item_unit_value", "receipt_value", "total_value"] + for col in money_candidates: + if col in df.columns: + df[col] = df[col].apply(_format_currency_brl) + + # SKU sem zeros à esquerda + for col in ["SKU", "product_code"]: + if col in df.columns: + def strip_zeros(v): + s = str(v or "").strip() + if not s: + return s + s2 = s.lstrip("0") + return s2 if s2 else "0" + df[col] = df[col].apply(strip_zeros) + + # Datas PT-BR + df = _format_dates_ptbr(df) + + # Remove colunas vazias + to_drop = [] + for col in df.columns: + serie = df[col] + empties = serie.isna() | (serie.astype(str).str.strip() == "") + if empties.all(): + to_drop.append(col) + if to_drop: + df = df.drop(columns=to_drop) + + return df + +# ===================================================== +# Helpers de KPI (somas seguras, moeda BRL, contagens) +# ===================================================== +def _to_num_brl(val) -> float: + """Converte 'R$ 1.234,56' ou num/str para float (1234.56).""" + if val is None: return 0.0 + if isinstance(val, (int, float)): return float(val) + s = str(val).strip() + if not s: return 0.0 + s = s.replace("R$", "").replace(" ", "") + s = s.replace(".", "").replace(",", ".") + try: + return float(s) + except Exception: + return 0.0 + +def _safe_sum(series: pd.Series) -> float: + """Soma segura de séries possivelmente formatadas em BRL (strings).""" + if series is None or series.empty: return 0.0 + return float(series.apply(_to_num_brl).sum()) + +def _nunique(df: pd.DataFrame, colnames: list) -> int: + """Conta distintos na primeira coluna existente em 'colnames'.""" + for c in colnames: + if c in df.columns: + return int(df[c].nunique(dropna=True)) + return 0 + +def _count_status(df: pd.DataFrame, col: str, positivos=("ATIVO","ACTIVE","BLOQUEADO","BLOCKED")) -> int: + """Conta registros com 'Status' em valores positivos (case-insensitive).""" + if col not in df.columns: return 0 + s = df[col].astype(str).str.upper() + return int(s.isin([p.upper() for p in positivos]).sum()) + +# ===================================================== +# KPIs dinâmicos por endpoint (usa colunas pós-RENAME_MAP) +# ===================================================== +def _build_kpis(path: str, df: pd.DataFrame) -> dict: + kpis = {} + + if path == "/address/blocking/list": + kpis["Endereços bloqueados (distintos)"] = _nunique(df, ["Endereço","location_code","location_id_code"]) + kpis["Bloqueios (linhas)"] = int(len(df)) + kpis["Motivos de bloqueio (distintos)"] = _nunique(df, ["Motivo Bloqueio","block_reason"]) + if "Status" in df.columns: + kpis["Bloqueios ativos"] = _count_status(df, "Status", positivos=("BLOQUEADO","BLOCKED")) + + elif path == "/address/list": + kpis["Endereços (distintos)"] = _nunique(df, ["Endereço","location_code","location_id_code"]) + if "Status" in df.columns: + kpis["Endereços ativos"] = _count_status(df, "Status", positivos=("ATIVO","ACTIVE")) + + elif path == "/stock/list": + # IMPORTANT: Valor total em estoque deve vir DA COLUNA 'Vr. Total' + qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0 + qtde_reserva = _safe_sum(df["Qtde Reservada"]) if "Qtde Reservada" in df.columns else 0.0 + valor_total = _safe_sum(df["Vr. Total"]) if "Vr. Total" in df.columns else 0.0 + + kpis["SKUs (distintos)"] = _nunique(df, ["SKU","product_code"]) + kpis["Itens em estoque (Qtde)"] = qtde_total + kpis["Itens reservados (Qtde)"] = qtde_reserva + kpis["Valor total em estoque (R$)"] = valor_total + kpis["Lotes (distintos)"] = _nunique(df, ["Lote","lot"]) + kpis["Endereços (distintos)"] = _nunique(df, ["Endereço","location_code"]) + kpis["Depositantes (distintos)"] = _nunique(df, ["Depositante","customer_description","customer_name"]) + + elif path == "/wsreceipt/list": + qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0 + valor_total = _safe_sum(df["Vr. Total"]) if "Vr. Total" in df.columns else 0.0 + kpis["NF de entrada (distintas)"] = _nunique(df, ["NF Entrada","wsreceipt_code"]) + kpis["Linhas (itens)"] = int(len(df)) + kpis["Qtde total (Entrada)"] = qtde_total + kpis["Valor total (Entrada) R$"] = valor_total + kpis["Depositantes (distintos)"] = _nunique(df, ["Depositante","customer_name"]) + + elif path == "/wsdispatch/list": + qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0 + valor_total = _safe_sum(df["Vr. Total"]) if "Vr. Total" in df.columns else 0.0 + kpis["NF de saída (distintas)"] = _nunique(df, ["NF Saída","wsdispatch_code"]) + kpis["Linhas (itens)"] = int(len(df)) + kpis["Qtde total (Saída)"] = qtde_total + kpis["Valor total (Saída) R$"] = valor_total + kpis["Destinatários (distintos)"] = _nunique(df, ["Destinatário","recipient_description"]) + + elif path == "/operation/list": + qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0 + kpis["Operações (linhas)"] = int(len(df)) + kpis["Tipos (distintos)"] = _nunique(df, ["Tipo","type"]) + kpis["Qtde total (Operações)"] = qtde_total + + elif path == "/product/list": + kpis["Produtos (linhas)"] = int(len(df)) + kpis["Categorias (distintas)"] = _nunique(df, ["Categoria","category_description"]) + kpis["Grupos (distintos)"] = _nunique(df, ["Grupo","group_description"]) + kpis["Status (distintos)"] = _nunique(df, ["Status","status"]) + + elif path == "/financial/invoice/list": + valor_total = _safe_sum(df["Vr. Total"]) if "Vr. Total" in df.columns else 0.0 + kpis["Faturas (distintas)"] = _nunique(df, ["Número Fatura","invoice_number"]) + kpis["Valor total (R$)"] = valor_total + kpis["Status (distintos)"] = _nunique(df, ["Status","status"]) + + elif path == "/monitor/nfe/list": + kpis["NFes (distintas)"] = _nunique(df, ["Chave NFe","nfe_key"]) + kpis["Status SEFAZ (distintos)"] = _nunique(df, ["Status SEFAZ","status"]) + + elif path == "/yms/scheduling/list": + kpis["Agendamentos (distintos)"] = _nunique(df, ["ID Agendamento","scheduling_id"]) + kpis["Status (distintos)"] = _nunique(df, ["Status","status"]) + kpis["Pátios (distintos)"] = _nunique(df, ["Pátio","yard"]) + kpis["Docas (distintas)"] = _nunique(df, ["Doca","dock"]) + + elif path == "/cargorelease/list": + qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0 + kpis["Pedidos (distintos)"] = _nunique(df, ["Pedido","cargorelease_code"]) + kpis["Status (distintos)"] = _nunique(df, ["Status","status"]) + kpis["Qtde total (Pedidos)"] = qtde_total + + else: + kpis["Linhas"] = int(len(df)) + + return kpis + +# ===================================================== +# Cache de consulta (DF final) — TTL configurável +# ===================================================== +@st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=True) +def _run_query_cached(path: str, base_guid: str, token: str, body_filter: dict, limit_per_page: int, max_pages_override, ui_common: dict, sbm_types_sel: list): + rows = _call_list_paginated( + path, base_guid, token, body_filter, + limit=limit_per_page, + max_pages_override=max_pages_override + ) + df = pd.json_normalize(rows) + rename = RENAME_MAP.get(path, {}) + if rename: + df = df.rename(columns={k: v for k, v in rename.items() if k in df.columns}) + df = _apply_client_side_filters(df, ui_common, sbm_types_sel, ui_common.get("types_sel", [])) + df = _format_dataframe(df) + return df + +# ===================================================== +# UI — Type(API), Destinatários, Endereços; Data operação (única); Categoria; SBM; Depositantes +# + Modo rápido (consulta leve) e limite por página +# + Debounce e Cancelar +# ===================================================== +def _render_form(report_label: str, token: str): + with st.form(f"form_{report_label.replace(' ', '_')}"): + base = st.selectbox("Base (x-user-enterprise-id):", list(BASES_MAP.keys()), index=0) + + # Performance + col_perf1, col_perf2 = st.columns(2) + with col_perf1: + quick_mode = st.checkbox("Consulta leve (rápida)", value=True, help="Coleta apenas 1 página e mostra colunas essenciais.") + with col_perf2: + limit_per_page = st.number_input("Itens por página", min_value=50, max_value=2000, value=500, step=50, help="Se suportado pelo servidor.") + + # Paginação manual (quando necessário) + colp1, colp2 = st.columns(2) + with colp1: + only_first = st.checkbox("Coletar apenas a primeira página (evitar limite/timeouts)", value=False) + with colp2: + max_pages_ui = st.number_input("Máximo de páginas (0=∞)", min_value=0, max_value=100, value=0, step=1) + + path = ENDPOINTS[report_label] + + # Type (API) multisseleção + types_sel = st.multiselect("Type (API)", OPERATION_TYPES, default=[]) + + # Data da operação (única) + data_op = st.date_input("Data da operação (opcional)", value=None) + + # Categoria (sugestões) multisseleção + categorias_opts = _load_suggestions_categories(BASES_MAP[base], token, path) + categorias_sel = st.multiselect("Categoria (sugestões)", categorias_opts, default=[]) + + # Endereços (sugestões) multisseleção + texto + addr_opts = _load_suggestions_addresses(BASES_MAP[base], token) # [(label, code)] + addr_labels = [lbl for (lbl, code) in addr_opts] + addr_values = [code for (lbl, code) in addr_opts] + end_sel_labels = st.multiselect("Endereços (sugestões)", addr_labels, default=[]) + enderecos_sel = [] + for sel in end_sel_labels: + i = addr_labels.index(sel) + enderecos_sel.append(addr_values[i]) + endereco_text = st.text_input("Endereço (texto)") + + # Nota Fiscal (apenas sugestões) + nf_opts = _load_suggestions_notas(BASES_MAP[base], token, path) + nota_fiscal_sel = st.selectbox("Nota Fiscal (sugestões)", [""] + nf_opts, index=0) + + # Item / SKU e Part Number + colA, colB = st.columns(2) + with colA: + sku_item = st.text_input("Item / SKU") + with colB: + part_number = st.text_input("Part Number") + + # Destinatários (sugestões multisseleção) + CNPJ múltiplos + dest_opts = _load_suggestions_recipients(BASES_MAP[base], token) # [(label, cnpj)] + dest_labels = [lbl for (lbl, cnpj) in dest_opts] + dest_values = [cnpj for (lbl, cnpj) in dest_opts] + dest_sel_labels = st.multiselect("Destinatários (sugestões)", dest_labels, default=[]) + destinatarios_docs = [] + for sel in dest_sel_labels: + i = dest_labels.index(sel) + destinatarios_docs.append(dest_values[i]) + + cnpj_livre_text = st.text_input("CNPJ(s) do destinatário (múltiplos, separados por vírgula)") + cnpjs_livres = [c.strip() for c in cnpj_livre_text.split(",")] if cnpj_livre_text.strip() else [] + + # Depositantes (sugestões multisseleção) + dep_opts = _load_suggestions_depositantes(BASES_MAP[base], token) # [(label, cnpj)] + dep_labels = [lbl for (lbl, cnpj) in dep_opts] + dep_values = [cnpj for (lbl, cnpj) in dep_opts] + dep_sel_labels = st.multiselect("Depositantes (sugestões)", dep_labels, default=[]) + depositantes_docs, depositantes_nomes = [], [] + for sel in dep_sel_labels: + i = dep_labels.index(sel) + depositantes_docs.append(dep_values[i]) + # Também guardamos o nome (parte antes do " (CNPJ)") para filtro por nome, se desejar + name = sel.split(" (")[0].strip() if " (" in sel else sel + depositantes_nomes.append(name) + + # Tipo de operação (SBM) multisseleção — baseado em Depositante + sbm_types_sel = st.multiselect("Tipo de operação (SBM)", SBM_TYPES, default=[]) if report_label == "Estoque" else [] + + # Server-side (quando suportado) + body = {} + if report_label == "Operações": + st.markdown("**Filtros de Operações (server-side)**") + if len(types_sel) == 1: + body["type"] = types_sel[0] + if data_op: + body["date_ini"] = data_op.strftime("%Y-%m-%d") + body["date_fim"] = data_op.strftime("%Y-%m-%d") + + elif report_label == "Agendamento": + st.markdown("**Filtros de Agendamento (YMS)**") + if data_op: + body["date_ini"] = data_op.strftime("%Y-%m-%d") + body["date_fim"] = data_op.strftime("%Y-%m-%d") + + ui_common = { + "enderecos_sel": enderecos_sel, + "endereco_text": endereco_text, + "nota_fiscal_sel": nota_fiscal_sel, # sem campo texto + "sku_item": sku_item, + "part_number": part_number, + "destinatarios_docs": destinatarios_docs, + "cnpjs_livres": cnpjs_livres, + "data_op": data_op, # único + "categorias_sel": categorias_sel, + "types_sel": types_sel, + "depositantes_docs": depositantes_docs, # multisseleção + "depositantes_nomes": depositantes_nomes, # para match por nome, se desejar + } + + mapped = _map_common_filters_to_body(path, ui_common) + body.update({k: v for k, v in mapped.items() if v}) + + submitted = st.form_submit_button("Consultar", type="primary", use_container_width=True) + + # Debounce leve + if submitted: + last_submit = st.session_state.get("__last_submit_ts__", 0) + now = time.time() + if now - last_submit < 2.0: + st.info("Aguarde um instante e evite clicar repetidamente em 'Consultar'.") + submitted = False + st.session_state["__last_submit_ts__"] = now + + max_pages_effective = 1 if only_first else int(max_pages_ui or 0) + return ( + submitted, + BASES_MAP[base], + max_pages_effective, + body, + ui_common, + sbm_types_sel, + quick_mode, + limit_per_page, + ) + +# ===================================================== +# TELA PRINCIPAL +# ===================================================== +def main(): + # Oculta nav lateral de outros módulos + st.markdown(""" + + """, unsafe_allow_html=True) + + st.title("⚙️ Operação | Relatórios via API (Mayasuite)") + + # Permissão + if not verificar_permissao("operacao") and st.session_state.get("perfil") != "admin": + st.error("⛔ Acesso não autorizado.") + return + + if not (OP_LOGIN_EMAIL and OP_LOGIN_PASSWORD) and not OP_ACCESS_TOKEN and not (OAUTH_TOKEN_URL and OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET): + st.warning("⚠️ Configure OP_LOGIN_EMAIL/OP_LOGIN_PASSWORD, ou OP_ACCESS_TOKEN, ou OAuth2 (OAUTH_TOKEN_URL/CLIENT_ID/CLIENT_SECRET) no .env (nunca hardcode).") + st.info("Ex.: OP_LOGIN_EMAIL=api@armmatriz.com.br / OP_LOGIN_PASSWORD=*** OU OP_ACCESS_TOKEN=jwt... OU OAuth2 client_credentials.") + return + + # Token antes do form (sugestões) + try: + token = _get_token(OP_LOGIN_EMAIL, OP_LOGIN_PASSWORD) + except Exception as e: + st.error(f"Login/API falhou: {e}") + return + + # Sidebar: token info + if OP_ACCESS_TOKEN: + payload = _jwt_payload(OP_ACCESS_TOKEN) + if payload: + st.sidebar.caption(f"🔑 Token carregado\n• iat: {_fmt_ts(payload.get('iat'))}\n• exp: {_fmt_ts(payload.get('exp'))}") + st.sidebar.caption(f"🌐 Base API: {OP_API_BASE_URL}") + + st.session_state["__active_module__"] = "operacao" + + st.sidebar.markdown("### Relatórios") + report_label = st.sidebar.selectbox("Selecione:", list(ENDPOINTS.keys()), index=0) + path = ENDPOINTS[report_label] + + # ⚡ Cache controls (TTL, limpar caches) + with st.sidebar.expander("⚡ Cache", expanded=False): + ttl_ui = st.slider("TTL do cache (minutos)", min_value=1, max_value=60, value=CACHE_TTL_MIN, step=1) + st.session_state["__cache_ttl_sec__"] = ttl_ui * 60 + colc1, colc2 = st.columns(2) + with colc1: + if st.button("🧹 Limpar cache de dados"): + try: + _run_query_cached.clear() + except Exception: + pass + st.success("Cache de dados limpo.") + st.rerun() + with colc2: + if st.button("🧹 Limpar cache de sugestões"): + try: + _load_suggestions_recipients.clear() + _load_suggestions_notas.clear() + _load_suggestions_categories.clear() + _load_suggestions_addresses.clear() + _load_suggestions_depositantes.clear() + except Exception: + pass + st.success("Cache de sugestões limpo.") + st.rerun() + + submitted, base_guid, max_pages_effective, body_filter, ui_common, sbm_types_sel, quick_mode, limit_per_page = _render_form(report_label, token) + if not submitted: + return + + # Botão cancelar + cancel_col, _ = st.columns([1,3]) + with cancel_col: + if st.button("⛔ Cancelar consulta"): + st.session_state[CANCEL_TOKEN_KEY] = True + st.warning("Consulta cancelada pelo usuário.") + return + st.session_state[CANCEL_TOKEN_KEY] = False + + # Modo rápido → força apenas 1 página + effective_max_pages = 1 if quick_mode else (max_pages_effective if max_pages_effective > 0 else None) + + # Executa consulta com cache + t0 = time.time() + try: + df = _run_query_cached(path, base_guid, token, body_filter, limit_per_page, effective_max_pages, ui_common, sbm_types_sel) + except Exception as e: + st.error(f"Falha na consulta: {e}") + # Botão para tentar novamente + if st.button("🔁 Tentar novamente agora"): + try: + _run_query_cached.clear() + df = _run_query_cached(path, base_guid, token, body_filter, limit_per_page, effective_max_pages, ui_common, sbm_types_sel) + except Exception as e2: + st.error(f"Falha na nova tentativa: {e2}") + return + else: + return + latency_ms = int((time.time() - t0) * 1000) + + if df.empty: + st.info("Nenhum registro retornado.") + return + + st.success(f"✅ Consulta concluída ({latency_ms} ms). Registros: {len(df)}") + pages_done = int(st.session_state.get("op_pages_processed", 0)) + st.caption(f"📄 Páginas coletadas: {pages_done}") + + # Visualização essencial em modo rápido + if quick_mode: + essential_cols = [c for c in [ + "Data Operação","Data","Nota Fiscal","NF Saída","SKU","Descrição","Qtde", + "Unidade","Endereço","Categoria","Destinatário","CNPJ_Destinatário","Tipo","Depositante","CNPJ_Depositante" + ] if c in df.columns] + df_view = df[essential_cols].copy() if essential_cols else df + else: + df_view = df + + # KPIs básicos + k1, k2, k3 = st.columns(3) + with k1: st.metric("Registros", len(df_view)) + with k2: st.metric("Latência (ms)", latency_ms) + with k3: st.metric("Colunas", df_view.shape[1]) + + # ======== NOVOS CARDS DINÂMICOS POR CONSULTA ======== + kpis = _build_kpis(path, df) # usa o df já renomeado/filtrado + if kpis: + st.markdown("#### 📈 Indicadores da consulta") + cols = st.columns(min(4, max(1, len(kpis)))) + for i, (label, value) in enumerate(kpis.items()): + # Formata moeda R$ quando aplicável + if isinstance(value, (int, float)) and ("R$" in label or "Valor" in label): + display_val = _format_currency_brl(value) + else: + display_val = value + cols[i % len(cols)].metric(label, display_val) + + # Tabela + st.dataframe(df_view, use_container_width=True) + + # Export + col_a, col_b = st.columns(2) + with col_a: + filtros_export = {"base": base_guid, "max_pages": effective_max_pages or 0, **body_filter, **{f"ui_{k}": v for k, v in ui_common.items()}, "sbm_types": sbm_types_sel} + excel_bytes = _export_excel(df, report_label, filtros_export) + st.download_button("📥 Exportar Excel", excel_bytes, file_name=f"operacao_{report_label.replace(' ','_')}.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet") + with col_b: + csv_bytes = _export_csv(df) + st.download_button("📥 Exportar CSV", csv_bytes, file_name=f"operacao_{report_label.replace(' ','_')}.csv", mime="text/csv") + + # Auditoria + try: + registrar_log(usuario=st.session_state.get("usuario"), + acao=f"Operação/API: {report_label} (base={base_guid}, {latency_ms}ms, reg={len(df)})", + tabela="operacao_api", registro_id=None) + except Exception: + pass + +# Se for executado diretamente (opcional para debug local) +if __name__ == "__main__": + st.set_page_config(page_title="Operação | ARM", layout="wide") + main() + + + + diff --git a/outlook_relatorio.py b/outlook_relatorio.py new file mode 100644 index 0000000000000000000000000000000000000000..ad5f2c148e670f1155238d74145f9f3e6145db5d --- /dev/null +++ b/outlook_relatorio.py @@ -0,0 +1,1624 @@ + +# -*- coding: utf-8 -*- +import streamlit as st +import pandas as pd +from datetime import datetime, timedelta, date +import io +import re +import unicodedata +import pythoncom # ✅ COM init/finalize para evitar 'CoInitialize não foi chamado' + +# (opcional) auditoria, se existir no seu projeto +try: + from utils_auditoria import registrar_log + _HAS_AUDIT = True +except Exception: + _HAS_AUDIT = False + +# ============================== +# 🎨 Estilos (UX) +# ============================== +_STYLES = """ + +""" + +# ============================== +# Utils — exportação / indicadores +# ============================== +def _build_downloads(df: pd.DataFrame, base_name: str): + """Cria botões de download (CSV, Excel e PDF) para o DataFrame.""" + if df.empty: + st.warning("Nenhum dado para exportar.") + return + + st.markdown('
', unsafe_allow_html=True) + + # CSV + csv_buf = io.StringIO() + df.to_csv(csv_buf, index=False, encoding="utf-8-sig") + st.download_button( + "⬇️ Baixar CSV", + data=csv_buf.getvalue(), + file_name=f"{base_name}.csv", + mime="text/csv", + key=f"dl_csv_{base_name}" + ) + + # Excel (com autoajuste de larguras) + xlsx_buf = io.BytesIO() + with pd.ExcelWriter(xlsx_buf, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Relatorio") + ws = writer.sheets["Relatorio"] + + # 🔎 Ajuste automático de largura das colunas + from openpyxl.utils import get_column_letter + for col_idx, col_cells in enumerate(ws.columns, start=1): + max_len = 0 + for cell in col_cells: + try: + v = "" if cell.value is None else str(cell.value) + max_len = max(max_len, len(v)) + except Exception: + pass + ws.column_dimensions[get_column_letter(col_idx)].width = min(max_len + 2, 60) + + xlsx_buf.seek(0) + st.download_button( + "⬇️ Baixar Excel", + data=xlsx_buf, + file_name=f"{base_name}.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + key=f"dl_xlsx_{base_name}" + ) + + # PDF (resumo até 100 linhas) + try: + from reportlab.lib.pagesizes import A4, landscape + from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer + from reportlab.lib import colors + from reportlab.lib.styles import getSampleStyleSheet + + pdf_buf = io.BytesIO() + doc = SimpleDocTemplate( + pdf_buf, pagesize=landscape(A4), + rightMargin=20, leftMargin=20, topMargin=20, bottomMargin=20 + ) + styles = getSampleStyleSheet() + story = [Paragraph(f"Relatório de E-mails — {base_name}", styles["Title"]), Spacer(1, 12)] + df_show = df.copy().head(100) + data_table = [list(df_show.columns)] + df_show.astype(str).values.tolist() + table = Table(data_table, repeatRows=1) + table.setStyle(TableStyle([ + ("BACKGROUND", (0,0), (-1,0), colors.HexColor("#E9ECEF")), + ("TEXTCOLOR", (0,0), (-1,0), colors.HexColor("#212529")), + ("GRID", (0,0), (-1,-1), 0.25, colors.HexColor("#ADB5BD")), + ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"), + ("FONTNAME", (0,1), (-1,-1), "Helvetica"), + ("FONTSIZE", (0,0), (-1,-1), 9), + ("ALIGN", (0,0), (-1,-1), "LEFT"), + ("VALIGN", (0,0), (-1,-1), "MIDDLE"), + ])) + story.append(table) + doc.build(story) + pdf_buf.seek(0) + st.download_button( + "⬇️ Baixar PDF", + data=pdf_buf, + file_name=f"{base_name}.pdf", + mime="application/pdf", + key=f"dl_pdf_{base_name}" + ) + except Exception as e: + st.info(f"PDF: não foi possível gerar o arquivo (ReportLab). Detalhe: {e}") + + st.markdown("
", unsafe_allow_html=True) # fecha dl-bar + + +# ====================================================== +# Helpers para clientes múltiplos e Indicadores visuais +# ====================================================== +def _ensure_client_exploded(df: pd.DataFrame) -> pd.DataFrame: + """ + Garante 'cliente_lista' a partir de 'cliente' separando por ';' quando necessário + e retorna um DF 'explodido' (uma linha por cliente), sem perder as colunas originais. + """ + if df.empty: + return df.copy() + + df2 = df.copy() + + if "cliente_lista" not in df2.columns and "cliente" in df2.columns: + def _split_by_semicolon(s): + if pd.isna(s): + return None + parts = [p.strip() for p in str(s).split(";")] + parts = [p for p in parts if p] + return parts or None + df2["cliente_lista"] = df2["cliente"].apply(_split_by_semicolon) + + if "cliente_lista" in df2.columns: + try: + exploded = df2.explode("cliente_lista", ignore_index=True) + exploded["cliente_lista"] = exploded["cliente_lista"].astype(str).str.strip() + exploded = exploded[exploded["cliente_lista"].notna() & (exploded["cliente_lista"].str.len() > 0)] + return exploded + except Exception: + return df2 + return df2 + +# ============================== +# ✅ NOVO: visão da Tabela (cliente único + Data/Hora + ícones) +# ============================== +def _build_table_view_unique_client(df: pd.DataFrame) -> pd.DataFrame: + """ + Prepara a Tabela com: + - Cliente único por linha (explode por ';') + - RecebidoEm separado em Data e Hora + - Colunas visuais (📎, 🔔, 👁️) + """ + if df.empty: + return df.copy() + + base = df.copy() + + # 1) Garante lista de clientes e "explode" + base = _ensure_client_exploded(base) + if "cliente_lista" not in base.columns and "cliente" in base.columns: + base["cliente_lista"] = base["cliente"].apply( + lambda s: [p.strip() for p in str(s).split(";") if p.strip()] if pd.notna(s) else None + ) + try: + base = base.explode("cliente_lista", ignore_index=True) + except Exception: + pass + + # Nome final do cliente para a Tabela + base["Cliente"] = base["cliente_lista"].where(base["cliente_lista"].notna(), base.get("cliente")) + + # 2) Separa RecebidoEm em Data e Hora + dt = pd.to_datetime(base.get("RecebidoEm"), errors="coerce") + base["Data"] = dt.dt.strftime("%d/%m/%Y").fillna("") + base["Hora"] = dt.dt.strftime("%H:%M").fillna("") + + # 3) Colunas visuais + anexos = base.get("Anexos") + if anexos is not None: + base["📎"] = anexos.fillna(0).astype(int).apply(lambda n: "📎" if n > 0 else "") + else: + base["📎"] = "" + + imp = base.get("Importancia") + if imp is not None: + mapa_imp = {"2": "🔴 Alta", "1": "🟡 Normal", "0": "⚪ Baixa", 2: "🔴 Alta", 1: "🟡 Normal", 0: "⚪ Baixa"} + base["🔔"] = base["Importancia"].map(mapa_imp).fillna("") + else: + base["🔔"] = "" + + if "Lido" in base.columns: + base["👁️"] = base["Lido"].apply(lambda v: "✅" if bool(v) else "⏳") + else: + base["👁️"] = "" + + # 4) Alias para Placa + if "placa" in base.columns and "Placa" not in base.columns: + base["Placa"] = base["placa"] + + # 5) Ordenação das colunas para leitura profissional + prefer = [ + "Data", "Hora", "Cliente", "tipo", "Placa", "Assunto", + "Status_join", "portaria", + "📎", "🔔", "👁️", + "Anexos", "TamanhoKB", "Remetente", + "PastaPath", "Pasta", + ] + prefer = [c for c in prefer if c in base.columns] + + drop_aux = {"cliente_lista"} # oculta auxiliar interna + others = [c for c in base.columns if c not in (set(prefer) | drop_aux | {"RecebidoEm"})] + cols = prefer + others + + df_show = base[cols].copy() + return df_show + +# ============================== +# Indicadores (visual + profissional) +# ============================== +import plotly.express as px + +def _render_indicators_custom(df: pd.DataFrame, dt_col_name: str, cols_for_topn: list[str], topn_default: int = 10): + """ + Indicadores com: + - Série temporal (gráfico) + - Top Clientes (tratando clientes múltiplos separados por ';') + - Outros Top N (Remetente, Tipo, Categoria, Status etc.) + Em layout de duas colunas para não ocupar a página inteira. + Inclui controles: Top N e Mostrar tabelas. + """ + if df.empty: + st.info("Nenhum dado após filtros. Ajuste os filtros para ver indicadores.") + return + + st.subheader("📊 Indicadores") + + # Controles globais dos Indicadores + ctl1, ctl2, ctl3 = st.columns([1,1,2]) + topn = ctl1.selectbox("Top N", [5, 10, 15, 20, 30], index=[5,10,15,20,30].index(topn_default)) + show_tables = ctl2.checkbox("Mostrar tabelas abaixo dos gráficos", value=True) + palette = ctl3.selectbox("Tema de cor", ["plotly_white", "plotly", "ggplot2", "seaborn"], index=0) + + # ===== 1) Série temporal ===== + if dt_col_name in df.columns: + try: + _dt = pd.to_datetime(df[dt_col_name], errors="coerce") + por_dia = _dt.dt.date.value_counts().sort_index() + if not por_dia.empty: + fig_ts = px.bar( + por_dia, x=por_dia.index, y=por_dia.values, + labels={"x": "Data", "y": "Qtd"}, + title="Mensagens por dia", + template=palette, + height=300, + ) + fig_ts.update_layout(margin=dict(l=10, r=10, t=50, b=10)) + st.plotly_chart(fig_ts, use_container_width=True) + except Exception: + pass + + # ===== 2) Top Clientes ===== + col_left, col_right = st.columns(2) + + with col_left: + st.markdown("### 👥 Top Clientes") + df_clients = _ensure_client_exploded(df) + + if "cliente_lista" in df_clients.columns: + vc_clientes = ( + df_clients["cliente_lista"] + .dropna() + .astype(str) + .str.strip() + .value_counts() + .head(topn) + ) + if not vc_clientes.empty: + ordered = vc_clientes.sort_values(ascending=True) + fig_cli = px.bar( + ordered, + x=ordered.values, y=ordered.index, orientation="h", + labels={"x": "Qtd", "y": "Cliente"}, + title=f"Top {topn} Clientes", + template=palette, height=420, + ) + fig_cli.update_layout(margin=dict(l=10, r=10, t=50, b=10)) + st.plotly_chart(fig_cli, use_container_width=True) + + if show_tables: + st.dataframe( + vc_clientes.rename("Qtd").to_frame(), + use_container_width=True, + height=300 + ) + else: + st.info("Não há clientes para exibir.") + else: + st.info("Coluna de clientes não disponível para indicador.") + + # ===== 3) Outros Top N ===== + with col_right: + st.markdown("### 🏆 Outros Top N") + ignore_cols = {"cliente", "cliente_lista"} + cols_others = [c for c in (cols_for_topn or []) if c in df.columns and c not in ignore_cols] + + if not cols_others: + fallback_cols = [c for c in ["Remetente", "tipo", "Categoria", "Status_join", "Pasta", "PastaPath"] if c in df.columns] + cols_others = fallback_cols[:3] # até 3 por padrão + + for col in cols_others[:4]: + try: + st.markdown(f"**Top {topn} por `{col}`**") + if df[col].apply(lambda x: isinstance(x, list)).any(): + exploded = df.explode(col) + serie = exploded[col].dropna().astype(str).str.strip().value_counts().head(topn) + else: + serie = df[col].dropna().astype(str).str.strip().value_counts().head(topn) + + if serie is None or serie.empty: + st.caption("Sem dados nesta coluna.") + continue + + ordered = serie.sort_values(ascending=True) + fig_any = px.bar( + ordered, + x=ordered.values, y=ordered.index, orientation="h", + labels={"x": "Qtd", "y": col}, + template=palette, height=260, + ) + fig_any.update_layout(margin=dict(l=10, r=10, t=30, b=10)) + st.plotly_chart(fig_any, use_container_width=True) + + if show_tables: + st.dataframe( + serie.rename("Qtd").to_frame(), + use_container_width=True, + height=220, + ) + except Exception as e: + st.warning(f"Não foi possível calcular TopN para `{col}`: {e}") + + +# ============================== +# Cards/KPIs — clientes, veículos e notas +# ============================== +def _count_notes(row) -> int: + """Conta notas em uma linha (lista em 'nota_fiscal' ou string).""" + v = row.get("nota_fiscal") + if isinstance(v, list): + return len([x for x in v if str(x).strip()]) + if isinstance(v, str) and v.strip(): + parts = [p for p in re.split(r"[^\d]+", v) if p.strip()] + return len(parts) + return 0 + +def _render_kpis(df: pd.DataFrame): + """Renderiza cards de KPIs e tabelas de resumo por cliente, incluindo tipos de operação.""" + if df.empty: + return + + # Preferimos 'cliente_lista' para múltiplos; caso contrário, 'cliente' + cliente_col = "cliente_lista" if "cliente_lista" in df.columns else ("cliente" if "cliente" in df.columns else None) + tipo_col = "tipo" if "tipo" in df.columns else None + placa_col = "placa" if "placa" in df.columns else ("Placa" if "Placa" in df.columns else None) + + _df = df.copy() + _df["__notes_count__"] = _df.apply(_count_notes, axis=1) + + # Se for lista, vamos explodir para análises por cliente + _df_exploded = None + if cliente_col == "cliente_lista": + try: + _df_exploded = _df.explode("cliente_lista") + _df_exploded["cliente_lista"] = _df_exploded["cliente_lista"].astype(str).str.strip() + except Exception: + _df_exploded = None + + # Clientes únicos + if cliente_col == "cliente_lista" and _df_exploded is not None: + clientes_unicos = _df_exploded["cliente_lista"].dropna().astype(str).str.strip().nunique() + elif cliente_col: + clientes_unicos = _df[cliente_col].dropna().astype(str).str.strip().nunique() + else: + clientes_unicos = 0 + + # Placas únicas + placas_unicas = _df[placa_col].dropna().nunique() if placa_col else 0 + + # Total de notas + notas_total = int(_df["__notes_count__"].sum()) + + st.markdown('
', unsafe_allow_html=True) + st.markdown( + f""" +
+
👥
Clientes (únicos)
+
{clientes_unicos}
+
Número total de clientes distintos no período.
+
+ """, + unsafe_allow_html=True, + ) + st.markdown( + f""" +
+
🚚
Placas (veículos únicos)
+
{placas_unicas}
+
Contagem de veículos distintos identificados.
+
+ """, + unsafe_allow_html=True, + ) + st.markdown( + f""" +
+
🧾
Notas (total)
+
{notas_total}
+
Soma de notas fiscais informadas nos e-mails.
+
+ """, + unsafe_allow_html=True, + ) + st.markdown("
", unsafe_allow_html=True) + + try: + media_placas_por_cliente = (placas_unicas / clientes_unicos) if clientes_unicos else 0 + media_notas_por_cliente = (notas_total / clientes_unicos) if clientes_unicos else 0 + st.caption( + f"📌 Média de **placas por cliente**: {media_placas_por_cliente:.2f} • " + f"Média de **notas por cliente**: {media_notas_por_cliente:.2f}" + ) + except Exception: + pass + + with st.expander("📒 Detalhes por cliente (resumo)", expanded=False): + # ✅ Operações por cliente e tipo + if cliente_col and tipo_col: + st.write("**Operações por cliente (contagem por tipo)**") + if cliente_col == "cliente_lista" and _df_exploded is not None: + operacoes_por_cliente = ( + _df_exploded.dropna(subset=["cliente_lista", tipo_col]) + .groupby(["cliente_lista", tipo_col]) + .size() + .unstack(fill_value=0) + .sort_index() + ) + else: + operacoes_por_cliente = ( + _df.dropna(subset=[cliente_col, tipo_col]) + .groupby([cliente_col, tipo_col]) + .size() + .unstack(fill_value=0) + .sort_index() + ) + st.dataframe(operacoes_por_cliente, use_container_width=True) + + # ✅ Gráfico de barras agrupadas + st.write("📊 **Gráfico: Operações por cliente e tipo**") + try: + df_plot = operacoes_por_cliente.reset_index().melt( + id_vars=(["cliente_lista"] if cliente_col == "cliente_lista" else [cliente_col]), + var_name="Tipo", value_name="Quantidade" + ) + x_col = "cliente_lista" if cliente_col == "cliente_lista" else cliente_col + fig = px.bar( + df_plot, x=x_col, y="Quantidade", color="Tipo", barmode="group", + title="Operações por Cliente e Tipo", text="Quantidade", template="plotly_white" + ) + fig.update_layout(xaxis_title="Cliente", yaxis_title="Quantidade", legend_title="Tipo", height=460) + st.plotly_chart(fig, use_container_width=True) + except Exception as e: + st.warning(f"Não foi possível gerar o gráfico: {e}") + else: + st.info("Sem colunas suficientes para resumo de operações por tipo.") + + # ✅ Veículos por cliente + if cliente_col and placa_col: + if cliente_col == "cliente_lista" and _df_exploded is not None: + veic_por_cliente = ( + _df_exploded.dropna(subset=["cliente_lista", placa_col]) + .groupby("cliente_lista")[placa_col].nunique() + .sort_values(ascending=False) + .rename("Placas_Únicas") + .to_frame() + ) + else: + veic_por_cliente = ( + _df.dropna(subset=[cliente_col, placa_col]) + .groupby(cliente_col)[placa_col].nunique() + .sort_values(ascending=False) + .rename("Placas_Únicas") + .to_frame() + ) + st.write("**Veículos (placas únicas) por cliente — Top 20**") + st.dataframe(veic_por_cliente.head(20), use_container_width=True, height=460) + else: + st.info("Sem colunas de cliente/placa suficientes para o resumo de veículos.") + + # ✅ Notas por cliente + if cliente_col: + if cliente_col == "cliente_lista" and _df_exploded is not None: + notas_por_cliente = ( + _df_exploded.groupby("cliente_lista")["__notes_count__"] + .sum() + .sort_values(ascending=False) + .rename("Notas_Total") + .to_frame() + ) + else: + notas_por_cliente = ( + _df.groupby(cliente_col)["__notes_count__"] + .sum() + .sort_values(ascending=False) + .rename("Notas_Total") + .to_frame() + ) + st.write("**Notas por cliente — Top 20**") + st.dataframe(notas_por_cliente.head(20), use_container_width=True, height=460) + else: + st.info("Sem coluna de cliente para o resumo de notas.") + +# ============================== +# Funções auxiliares — normalização / validação +# ============================== +def _strip_accents(s: str) -> str: + if not isinstance(s, str): + return s + return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn") + +def _html_to_text(html: str) -> str: + if not html: + return "" + text = re.sub(r"(?is).*?", " ", html) + text = re.sub(r"(?is).*?", " ", text) + text = re.sub(r"(?is)", "\n", text) + text = re.sub(r"(?is)

", "\n", text) + text = re.sub(r"(?is)<.*?>", " ", text) + text = re.sub(r"[ \t]+", " ", text) + text = re.sub(r"\s*\n\s*", "\n", text).strip() + return text + +def _normalize_spaces(s: str) -> str: + if not s: + return "" + s = s.replace("\r", "\n") + s = re.sub(r"[ \t]+", " ", s) + s = re.sub(r"\n{2,}", "\n", s).strip() + return s + +def _normalize_person_name(s: str) -> str: + """ + Converte 'Sobrenome, Nome' -> 'Nome Sobrenome'. + Mantém como está se não houver vírgula. + """ + if not s: + return s + s = s.strip() + if "," in s: + partes = [p.strip() for p in s.split(",")] + if len(partes) >= 2: + return f"{partes[1]} {partes[0]}".strip() + return s + +# ====== Utilitário robusto para extrair e validar PLACA ====== +def _clean_plate(p: str) -> str: + """Normaliza a placa: remove espaços/hífens e deixa maiúscula.""" + return re.sub(r"[^A-Za-z0-9]", "", (p or "")).upper() + +def _is_valid_plate(p: str) -> bool: + """ + Valida placa nos formatos: + - antigo: AAA1234 + - Mercosul: AAA1B23 + """ + if not p: + return False + p = _clean_plate(p) + if len(p) != 7: + return False + if re.fullmatch(r"[A-Z]{3}[0-9]{4}", p): # antigo + return True + if re.fullmatch(r"[A-Z]{3}[0-9][A-Z][0-9]{2}", p): # Mercosul + return True + return False + +def _format_plate(p: str) -> str: + """Formata visualmente: antigo → AAA-1234; Mercosul permanece sem hífen.""" + p0 = _clean_plate(p) + if re.fullmatch(r"[A-Z]{3}[0-9]{4}", p0): + return f"{p0[:3]}-{p0[3:]}" + return p0 + +def _extract_plate_from_text(text: str) -> str | None: + """Extrai placa do texto com robustez.""" + if not text: + return None + + t = _strip_accents(text).upper() + t = re.sub(r"PLACA\s+DE\s+AUTORIZA\w+", " ", t) + + # 1) Por linhas contendo 'PLACA' e NÃO 'AUTORIZA' + for ln in [x.strip() for x in t.splitlines() if x.strip()]: + if "PLACA" in ln and "AUTORIZA" not in ln: + for m in re.finditer(r"\b[A-Z0-9]{7}\b", ln): + cand = m.group(0) + if _is_valid_plate(cand): + return _format_plate(cand) + m_old = re.search(r"\b([A-Z]{3})[ -]?([0-9]{4})\b", ln) + if m_old: + cand = (m_old.group(1) + m_old.group(2)).upper() + if _is_valid_plate(cand): + return _format_plate(cand) + m_new = re.search(r"\b([A-Z]{3})[ -]?([0-9])[ -]?([A-Z])[ -]?([0-9]{2})\b", ln) + if m_new: + cand = (m_new.group(1) + m_new.group(2) + m_new.group(3) + m_new.group(4)).upper() + if _is_valid_plate(cand): + return _format_plate(cand) + + # 2) Fallbacks + for m in re.finditer(r"\b([A-Z]{3})[ -]?([0-9]{4})\b", t): + cand = (m.group(1) + m.group(2)).upper() + if _is_valid_plate(cand): + return _format_plate(cand) + for m in re.finditer(r"\b([A-Z]{3})[ -]?([0-9])[ -]?([A-Z])[ -]?([0-9]{2})\b", t): + cand = (m.group(1) + m.group(2) + m.group(3) + m.group(4)).upper() + if _is_valid_plate(cand): + return _format_plate(cand) + + return None + +# ====== Sanitização de CLIENTE ====== +def _sanitize_cliente(s: str | None) -> str | None: + if not s: + return s + s2 = re.sub(r"\s*:\s*", " ", s) + s2 = re.sub(r"\s+", " ", s2).strip(" -:|") + return s2.strip() + +def _split_semicolon(s: str | None) -> list[str] | None: + if not s: + return None + parts = [p.strip(" -:|").strip() for p in s.split(";")] + parts = [p for p in parts if p] + return parts or None + +def _cliente_to_list(cliente_raw: str | None) -> list[str] | None: + s = _sanitize_cliente(cliente_raw) + return _split_semicolon(s) + +# ============================== +# Parser do corpo do e‑mail (modelo padrão) +# ============================== +def _parse_email_body(raw_text: str) -> dict: + """Extrai campos estruturados do e‑mail padrão.""" + text = _normalize_spaces(raw_text or "") + text_acc = _strip_accents(text) + + def get(pat, acc=False, flags=re.IGNORECASE | re.MULTILINE): + return (re.search(pat, text_acc if acc else text, flags) or re.search(pat, text, flags)) + + data = {} + m = get(r"\bItem\s+ID\s*([0-9]+)") + data["item_id"] = m.group(1) if m else None + + m = get(r"Portaria\s*-\s*([^\n]+)") + data["portaria"] = (m.group(1).strip() if m else None) + + m = get(r"\b([0-9]{2}/[0-9]{2}/[0-9]{4})\s+([0-9]{2}:[0-9]{2})\b") + data["timestamp_corpo_data"] = m.group(1) if m else None + data["timestamp_corpo_hora"] = m.group(2) if m else None + + m = get(r"NOTA\s+FISCAL\s*([0-9/\- ]+)") + nf = None + if m: + nf = re.sub(r"\s+", "", m.group(1)) + nf = [p for p in nf.split("/") if p] + data["nota_fiscal"] = nf + + m = get(r"TIPO\s*([A-ZÁÂÃÉÍÓÚÇ ]+)") + data["tipo"] = (m.group(1).strip() if m else None) + + m = get(r"N[º°]?\s*DA\s*PLACA\s*DE\s*AUTORIZA[ÇC]AO\s*([0-9]+)", acc=True) + data["num_placa_autorizacao"] = m.group(1) if m else None + + # CLIENTE — ":" opcional + m = get(r"CLIENTE\s*:?\s*([^\n]+)") + cliente_raw = (m.group(1).strip() if m else None) + data["cliente"] = _sanitize_cliente(cliente_raw) + data["cliente_lista"] = _cliente_to_list(cliente_raw) + + # STATUS — ":" opcional; separa por múltiplos espaços/tab + m = get(r"STATUS\s*:?\s*([^\n]+)") + status_raw = m.group(1).strip() if m else None + data["status_lista"] = [p.strip() for p in re.split(r"\s{2,}|\t", status_raw) if p.strip()] if status_raw else None + + # Liberações (ENTRADA/SAÍDA) + m = get(r"LIBERA[ÇC][AÃ]O\s*DE\s*ENTRADA\s*([^\n]*)", acc=True) + liber_entr = (m.group(1).strip() or None) if m else None + data["liberacao_entrada"] = liber_entr + data["liberacao_entrada_responsavel"] = _normalize_person_name(liber_entr) if liber_entr else None + data["liberacao_entrada_flag"] = bool(liber_entr) + + m = get(r"LIBERA[ÇC][AÃ]O\s*DE\s*SA[IÍ]DA\s*([^\n]*)", acc=True) + liber_saida = (m.group(1).strip() or None) if m else None + data["liberacao_saida"] = liber_saida + data["liberacao_saida_responsavel"] = _normalize_person_name(liber_saida) if liber_saida else None + data["liberacao_saida_flag"] = bool(liber_saida) + + m = get(r"RG\s*ou\s*CPF\s*do\s*MOTORISTA\s*([0-9.\-]+)") + data["rg_cpf_motorista"] = m.group(1) if m else None + + # Placa do veículo + data["placa"] = _extract_plate_from_text(text) + + # Horários + m = get(r"HR\s*ENTRADA\s*:?\s*([0-9]{2}:[0-9]{2})") + data["hr_entrada"] = m.group(1) if m else None + + m = get(r"HR\s*SA[IÍ]DA\s*:?\s*([0-9]{2}:[0-9]{2})", acc=True) + data["hr_saida"] = m.group(1) if m else None + edit_saida = get(r"HR\s*SA[IÍ]DA.*?\bEditado\b", acc=True, flags=re.IGNORECASE | re.DOTALL) + data["hr_saida_editado"] = bool(edit_saida) + + m = get(r"DATA\s*DA\s*OPERA[ÇC][AÃ]O\s*([0-9]{2}/[0-9]{2}/[0-9]{4})", acc=True) + data["data_operacao"] = m.group(1) if m else None + + m = get(r"Palavras[- ]chave\s*Corporativas\s*([^\n]*)", acc=True) + data["palavras_chave"] = (m.group(1).strip() or None) if m else None + + m = get(r"AGENDAMENTO\s*(SIM|N[ÃA]O)", acc=True) + agend = m.group(1).upper() if m else None + data["agendamento"] = {"SIM": True, "NAO": False, "NÃO": False}.get(agend, None) + + data["status_editado"] = any("editado" in p.lower() for p in (data["status_lista"] or [])) + data["Status_join"] = " | ".join(data["status_lista"]) if isinstance(data.get("status_lista"), list) else (data.get("status_lista") or "") + return data + +# ============================== +# Outlook Desktop — listagem / leitura +# ============================== +def _list_inbox_paths(ns) -> list[str]: + """Inbox da caixa padrão (para retrocompatibilidade).""" + paths = ["Inbox"] + try: + olFolderInbox = 6 + inbox = ns.GetDefaultFolder(olFolderInbox) + except Exception: + return [] + + def _walk(folder, prefix="Inbox"): + try: + for i in range(1, folder.Folders.Count + 1): + f = folder.Folders.Item(i) + full_path = prefix + "\\" + f.Name + paths.append(full_path) + _walk(f, full_path) + except Exception: + pass + + _walk(inbox, "Inbox") + return paths + +def _list_all_inbox_paths(ns) -> list[str]: + """Lista caminhos de Inbox para TODAS as stores (caixas), ex.: 'Minha Caixa\\Inbox\\Sub'.""" + paths: list[str] = [] + olFolderInbox = 6 + try: + for i in range(1, ns.Folders.Count + 1): + store = ns.Folders.Item(i) + root_name = str(store.Name).strip() + try: + inbox = store.GetDefaultFolder(olFolderInbox) + except Exception: + continue + inbox_display = str(inbox.Name).strip() + root_prefix = f"{root_name}\\{inbox_display}" + paths.append(root_prefix) + + def _walk(folder, prefix): + try: + for j in range(1, folder.Folders.Count + 1): + f2 = folder.Folders.Item(j) + full_path = prefix + "\\" + f2.Name + paths.append(full_path) + _walk(f2, full_path) + except Exception: + pass + + _walk(inbox, root_prefix) + except Exception: + pass + return sorted(set(paths)) + +def _get_folder_by_path_any_store(ns, path: str): + """ + Resolve caminho de pasta começando por: + - 'Inbox\\...' (ou nome local, ex.: 'Caixa de Entrada\\...') + - 'Sent Items\\...' (ou nome local, ex.: 'Itens Enviados\\...') + - 'NomeDaStore\\Inbox\\...' OU 'NomeDaStore\\\\...' + - 'NomeDaStore\\Sent Items\\...' OU 'NomeDaStore\\\\...' + Faz match case-insensitive nas subpastas. + """ + OL_INBOX = 6 + OL_SENT = 5 + + p = (path or "").strip().replace("/", "\\") + parts = [x for x in p.split("\\") if x] + if not parts: + raise RuntimeError("Caminho da pasta vazio. Ex.: Inbox\\Financeiro, Sent Items\\Faturamento ou Minha Caixa\\Inbox\\Operacional") + + # Nomes locais (ex.: PT-BR) + try: + inbox_local = ns.GetDefaultFolder(OL_INBOX).Name + except Exception: + inbox_local = "Inbox" + try: + sent_local = ns.GetDefaultFolder(OL_SENT).Name + except Exception: + sent_local = "Sent Items" + + inbox_names = {"inbox", str(inbox_local).strip().lower()} + sent_names = {"sent items", str(sent_local).strip().lower()} + + def _descend_from(root_folder, segs): + folder = root_folder + for seg in segs: + target = seg.strip().lower() + found = None + for i in range(1, folder.Folders.Count + 1): + cand = folder.Folders.Item(i) + if str(cand.Name).strip().lower() == target: + found = cand + break + if not found: + raise RuntimeError(f"Subpasta não encontrada: '{seg}' em '{folder.Name}'") + folder = found + return folder + + # 1) Tenta como 'Store\\(Inbox|Sent Items)\\...' + if len(parts) >= 2: + store_name_candidate = parts[0].strip().lower() + for i in range(1, ns.Folders.Count + 1): + store = ns.Folders.Item(i) + if str(store.Name).strip().lower() == store_name_candidate: + first_seg = parts[1].strip().lower() + if first_seg in inbox_names: + root = store.GetDefaultFolder(OL_INBOX) + return _descend_from(root, parts[2:]) + if first_seg in sent_names: + root = store.GetDefaultFolder(OL_SENT) + return _descend_from(root, parts[2:]) + raise RuntimeError( + f"Segundo segmento deve ser 'Inbox'/'{inbox_local}' ou 'Sent Items'/'{sent_local}' para a store '{store.Name}'." + ) + + # 2) Caso contrário, usa a store padrão (Inbox ou Sent Items conforme o primeiro segmento) + first = parts[0].strip().lower() + if first in inbox_names: + root = ns.GetDefaultFolder(OL_INBOX) + return _descend_from(root, parts[1:]) + if first in sent_names: + root = ns.GetDefaultFolder(OL_SENT) + return _descend_from(root, parts[1:]) + + raise RuntimeError( + f"O caminho deve começar por 'Inbox' (ou '{inbox_local}') ou 'Sent Items' (ou '{sent_local}'), " + f"ou por 'NomeDaStore\\Inbox' / 'NomeDaStore\\Sent Items'." + ) + +def _read_folder_dataframe(folder, path: str, dias: int, filtro_remetente: str, extrair_campos: bool) -> pd.DataFrame: + """ + Lê e-mails de uma pasta específica e retorna DataFrame. + ✅ Fallback: se Restrict retornar 0 itens, volta para iteração completa + filtro por data. + """ + items = folder.Items + items.Sort("[ReceivedTime]", True) # decrescente + cutoff = datetime.now() - timedelta(days=dias) + + iter_items = items + restricted_ok = False + restricted_count = None + try: + # Formato recomendado pelo Outlook MAPI: US locale "mm/dd/yyyy hh:mm AM/PM" + dt_from = cutoff.strftime("%m/%d/%Y %I:%M %p") + iter_items = items.Restrict(f"[ReceivedTime] >= '{dt_from}'") + restricted_ok = True + try: + restricted_count = iter_items.Count + except Exception: + restricted_count = None + except Exception: + restricted_ok = False + + # ✅ Se Restrict "funcionou" mas não retornou nada, aplica fallback + if restricted_ok and (restricted_count is None or restricted_count == 0): + iter_items = items + restricted_ok = False + + rows = [] + total_lidos = 0 + for mail in iter_items: + total_lidos += 1 + try: + if getattr(mail, "Class", None) != 43: # 43 = MailItem + continue + + # Filtro manual de data quando Restrict não foi usado + if not restricted_ok: + try: + if getattr(mail, "ReceivedTime", None) and mail.ReceivedTime < cutoff: + continue + except Exception: + pass + + try: + sender = mail.SenderEmailAddress or mail.Sender.Name + except Exception: + sender = getattr(mail, "SenderName", None) + + if filtro_remetente and sender: + if filtro_remetente.lower() not in str(sender).lower(): + continue + + base = { + "Pasta": folder.Name, + "PastaPath": path, + "Assunto": mail.Subject, + "Remetente": sender, + "RecebidoEm": mail.ReceivedTime.strftime("%Y-%m-%d %H:%M"), + "Anexos": mail.Attachments.Count if hasattr(mail, "Attachments") else 0, + "TamanhoKB": round(mail.Size / 1024, 1) if hasattr(mail, "Size") else None, + "Importancia": str(getattr(mail, "Importance", "")), + "Categoria": getattr(mail, "Categories", "") or "", + "Lido": bool(getattr(mail, "UnRead", False) == False), + } + + if extrair_campos: + raw_body = "" + try: + raw_body = mail.Body or "" + except Exception: + raw_body = "" + if not raw_body: + try: + raw_body = _html_to_text(mail.HTMLBody) + except Exception: + raw_body = "" + + parsed = _parse_email_body(raw_body) + base.update(parsed) + + try: + base["__corpo__"] = _strip_accents(raw_body)[:800] + except Exception: + base["__corpo__"] = "" + + rows.append(base) + except Exception as e: + rows.append({"Pasta": folder.Name, "PastaPath": path, "Assunto": f"[ERRO] {e}"}) + + df = pd.DataFrame(rows) + with st.expander(f"🔎 Diagnóstico • {path}", expanded=False): + st.caption( + f"Itens enumerados: **{total_lidos}** " + f"| Restrict aplicado: **{restricted_ok}** " + f"{'| Restrict.Count=' + str(restricted_count) if restricted_count is not None else ''} " + f"| Registros DF: **{df.shape[0]}**" + ) + return df + +def gerar_relatorio_outlook_desktop_multi( + pastas: list[str], + dias: int, + filtro_remetente: str = "", + extrair_campos: bool = True +) -> pd.DataFrame: + """Inicializa COM, conecta ao Outlook, lê múltiplas pastas (todas as stores) e finaliza COM.""" + try: + import win32com.client + pythoncom.CoInitialize() + ns = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI") + except Exception as e: + st.error(f"Falha ao conectar ao Outlook/pywin32: {e}") + return pd.DataFrame() + + frames = [] + try: + for path in pastas: + try: + folder = _get_folder_by_path_any_store(ns, path) + df = _read_folder_dataframe(folder, path, dias, filtro_remetente, extrair_campos) + frames.append(df) + except Exception as e: + st.warning(f"Não foi possível ler a pasta '{path}': {e}") + + return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() + finally: + try: + pythoncom.CoUninitialize() + except Exception: + pass + +# ============================== +# Cache / refresh control +# ============================== +@st.cache_data(show_spinner=False, ttl=60) +def _cache_outlook_df(pastas: tuple, dias: int, filtro_remetente: str, extrair_campos: bool, _v: int = 1) -> pd.DataFrame: + """Cache com TTL para reduzir leituras do Outlook.""" + return gerar_relatorio_outlook_desktop_multi(list(pastas), dias, filtro_remetente=filtro_remetente, extrair_campos=extrair_campos) + +def _read_outlook_fresh(pastas: list[str], dias: int, filtro_remetente: str, extrair_campos: bool) -> pd.DataFrame: + """Leitura direta, sem cache (para 'Atualizar agora').""" + return gerar_relatorio_outlook_desktop_multi(pastas, dias, filtro_remetente=filtro_remetente, extrair_campos=extrair_campos) + +# ============================== +# Filtros dinâmicos (persistentes) + aplicação manual +# ============================== +def _sanitize_default(options: list[str], default_list: list[str]) -> list[str]: + if not options or not default_list: + return [] + opts_set = set(options) + return [v for v in default_list if v in opts_set] + +def _build_dynamic_filters(df: pd.DataFrame): + """ + Constrói UI de filtros e retorna (df_filtrado, cols_topn). + """ + if df.empty: + return df, [] + + df_f = df.copy() + try: + df_f["RecebidoEm_dt"] = pd.to_datetime(df_f.get("RecebidoEm"), errors="coerce") + except Exception: + df_f["RecebidoEm_dt"] = pd.NaT + + total_inicial = len(df_f) + + state_keys = { + "f_dt_ini": "flt_dt_ini", + "f_dt_fim": "flt_dt_fim", + "f_lido": "flt_lido", + "f_status": "flt_status", + "f_saida_flag": "flt_saida_flag", + "f_entrada_flag": "flt_entrada_flag", + "f_anexos_equal": "flt_anexos_equal", + "f_anexos_apply_equal": "flt_anexos_apply_equal", + "f_tamanho_equal": "flt_tamanho_equal", + "f_tamanho_apply_equal": "flt_tamanho_apply_equal", + "f_assunto": "flt_assunto", + "f_cols_topn": "flt_cols_topn", + "f_pasta": "flt_pasta", + "f_pasta_path": "flt_pasta_path", + "f_portaria": "flt_portaria", + "f_cliente": "flt_cliente", + "f_tipo": "flt_tipo", + "f_placa": "flt_placa", + "f_importancia": "flt_importancia", + "f_categoria": "flt_categoria", + "f_resp_saida": "flt_resp_saida", + "f_resp_entrada": "flt_resp_entrada", + } + for k in state_keys.values(): + st.session_state.setdefault(k, None) + + with st.expander("🔎 Filtros do relatório (por colunas retornadas)", expanded=True): + with st.form("form_filtros_outlook"): + # Período + min_dt = df_f["RecebidoEm_dt"].min() + max_dt = df_f["RecebidoEm_dt"].max() + if pd.notna(min_dt) and pd.notna(max_dt): + col_d1, col_d2 = st.columns(2) + dt_ini = col_d1.date_input("Data inicial (RecebidoEm)", value=st.session_state[state_keys["f_dt_ini"]] or min_dt.date(), key="f_dt_ini_widget") + dt_fim = col_d2.date_input("Data final (RecebidoEm)", value=st.session_state[state_keys["f_dt_fim"]] or max_dt.date(), key="f_dt_fim_widget") + else: + dt_ini, dt_fim = None, None + + def _multi_select(col_name, label, state_key): + if col_name in df_f.columns: + options = sorted([v for v in df_f[col_name].dropna().astype(str).unique() if v != ""]) + default_raw = st.session_state[state_keys[state_key]] or [] + default = _sanitize_default(options, default_raw) + sel = st.multiselect(label, options=options, default=default, key=f"{state_key}_widget") + st.session_state[state_keys[state_key]] = sel + + # ✅ Pasta (caminho completo) — preferido + _multi_select("PastaPath", "Pasta (caminho completo)", "f_pasta_path") + # Opcional — nome curto + _multi_select("Pasta", "Pasta (apenas nome)", "f_pasta") + + _multi_select("portaria", "Portaria", "f_portaria") + + # Clientes (múltiplos) + if "cliente_lista" in df_f.columns: + clientes_opts = sorted({ + c.strip() for lst in df_f["cliente_lista"] + if isinstance(lst, list) for c in lst if c and str(c).strip() + }) + default_raw = st.session_state[state_keys["f_cliente"]] or [] + default = _sanitize_default(clientes_opts, default_raw) + cliente_sel = st.multiselect("Cliente", options=clientes_opts, default=default, key="f_cliente_widget") + st.session_state[state_keys["f_cliente"]] = cliente_sel + else: + _multi_select("cliente", "Cliente", "f_cliente") + + _multi_select("tipo", "Tipo", "f_tipo") + _multi_select("placa", "Placa (veículo)", "f_placa") + _multi_select("Importancia", "Importância (0=baixa,1=normal,2=alta)", "f_importancia") + _multi_select("Categoria", "Categoria", "f_categoria") + _multi_select("liberacao_saida_responsavel", "Responsável (Liberação de Saída)", "f_resp_saida") + _multi_select("liberacao_entrada_responsavel", "Responsável (Liberação de Entrada)", "f_resp_entrada") + + # Lido? + if "Lido" in df_f.columns: + options_lido = ["True", "False"] + default_raw = st.session_state[state_keys["f_lido"]] or [] + default_lido = _sanitize_default(options_lido, default_raw) + lido_sel = st.multiselect("Lido?", options=options_lido, default=default_lido, key="f_lido_widget") + st.session_state[state_keys["f_lido"]] = lido_sel + + # Status + if "status_lista" in df_f.columns: + all_status = sorted(set(s for v in df_f["status_lista"] if isinstance(v, list) for s in v)) + default_raw = st.session_state[state_keys["f_status"]] or [] + default_status = _sanitize_default(all_status, default_raw) + status_sel = st.multiselect("Status (qualquer dos selecionados)", options=all_status, default=default_status, key="f_status_widget") + st.session_state[state_keys["f_status"]] = status_sel + + # Flags + if "liberacao_saida_flag" in df_f.columns: + opt_saida = st.selectbox("Teve Liberação de Saída?", ["(todas)", "Sim", "Não"], + index=(0 if st.session_state[state_keys["f_saida_flag"]] is None else ["(todas)", "Sim", "Não"].index(st.session_state[state_keys["f_saida_flag"]])), + key="f_saida_flag_widget") + st.session_state[state_keys["f_saida_flag"]] = opt_saida + if "liberacao_entrada_flag" in df_f.columns: + opt_entr = st.selectbox("Teve Liberação de Entrada?", ["(todas)", "Sim", "Não"], + index=(0 if st.session_state[state_keys["f_entrada_flag"]] is None else ["(todas)", "Sim", "Não"].index(st.session_state[state_keys["f_entrada_flag"]])), + key="f_entrada_flag_widget") + st.session_state[state_keys["f_entrada_flag"]] = opt_entr + + # Numéricos + col_n1, col_n2 = st.columns(2) + if "Anexos" in df_f.columns: + col_vals = df_f["Anexos"].dropna() + if col_vals.empty: + col_n1.info("Sem valores para **Anexos** nesta seleção.") + else: + min_ax = int(col_vals.min()); max_ax = int(col_vals.max()) + if min_ax == max_ax: + col_n1.info(f"Todos os registros têm **{min_ax}** anexos.") + eq_val = col_n1.number_input("Filtrar por Anexos = (opcional)", min_value=min_ax, max_value=max_ax, + value=st.session_state[state_keys["f_anexos_equal"]] or min_ax, step=1, key="f_anexos_equal_widget") + apply_eq = col_n1.checkbox("Aplicar igualdade de Anexos", value=bool(st.session_state[state_keys["f_anexos_apply_equal"]]), key="f_anexos_apply_equal_widget") + st.session_state[state_keys["f_anexos_equal"]] = eq_val + st.session_state[state_keys["f_anexos_apply_equal"]] = apply_eq + else: + rng_ax = col_n1.slider("Anexos (intervalo)", min_value=min_ax, max_value=max_ax, + value=st.session_state.get("__rng_anexos__", (min_ax, max_ax)), key="__rng_anexos__widget") + st.session_state["__rng_anexos__"] = rng_ax + + if "TamanhoKB" in df_f.columns: + col_vals = df_f["TamanhoKB"].dropna() + if col_vals.empty: + col_n2.info("Sem valores para **TamanhoKB** nesta seleção.") + else: + min_sz = float(col_vals.min()); max_sz = float(col_vals.max()) + if abs(min_sz - max_sz) < 1e-9: + col_n2.info(f"Todos os registros têm **{min_sz:.1f} KB**.") + eq_sz = col_n2.number_input("Filtrar por TamanhoKB = (opcional)", min_value=float(min_sz), max_value=float(max_sz), + value=st.session_state[state_keys["f_tamanho_equal"]] or float(min_sz), step=0.1, format="%.1f", key="f_tamanho_equal_widget") + apply_sz = col_n2.checkbox("Aplicar igualdade de TamanhoKB", value=bool(st.session_state[state_keys["f_tamanho_apply_equal"]]), key="f_tamanho_apply_equal_widget") + st.session_state[state_keys["f_tamanho_equal"]] = eq_sz + st.session_state[state_keys["f_tamanho_apply_equal"]] = apply_sz + else: + rng_sz = col_n2.slider("Tamanho (KB)", min_value=float(min_sz), max_value=float(max_sz), + value=st.session_state.get("__rng_tamanho__", (float(min_sz), float(max_sz))), key="__rng_tamanho__widget") + st.session_state["__rng_tamanho__"] = rng_sz + + # Texto — Assunto contém + txt_default = st.session_state[state_keys["f_assunto"]] or "" + txt = st.text_input("Assunto contém (opcional)", value=txt_default, key="f_assunto_widget") + st.session_state[state_keys["f_assunto"]] = txt + + # Indicadores — Top N + possiveis_cols = [c for c in ["Pasta", "PastaPath", "portaria", "cliente", "cliente_lista", "tipo", "placa", + "Importancia", "Categoria", "Remetente", "Status_join", + "liberacao_saida_responsavel", "liberacao_entrada_responsavel"] if c in df_f.columns] + desired_default = st.session_state[state_keys["f_cols_topn"]] or ["cliente", "Status_join"] + default_cols = _sanitize_default(possiveis_cols, desired_default) + cols_for_topn = st.multiselect("Colunas para indicadores (Top N)", options=possiveis_cols, default=default_cols, key="f_cols_topn_widget") + st.session_state[state_keys["f_cols_topn"]] = cols_for_topn + + # Botões + col_b1, col_b2 = st.columns(2) + aplicar = col_b1.form_submit_button("✅ Aplicar filtros") + limpar = col_b2.form_submit_button("🧹 Limpar filtros") + + if limpar: + for k in state_keys.values(): + st.session_state[k] = None + st.session_state.pop("__rng_anexos__", None) + st.session_state.pop("__rng_tamanho__", None) + st.info("Filtros limpos.") + return df, [] + + if aplicar: + # período + if pd.notna(df_f["RecebidoEm_dt"]).any() and dt_ini and dt_fim: + mask_dt = (df_f["RecebidoEm_dt"].dt.date >= dt_ini) & (df_f["RecebidoEm_dt"].dt.date <= dt_fim) + df_f = df_f[mask_dt] + + def _apply_multi(col_name, session_key): + sel = st.session_state.get(session_key) + nonlocal df_f + if sel and col_name in df_f.columns: + df_f = df_f[df_f[col_name].astype(str).isin(sel)] + + # ✅ aplica pelo caminho completo (preciso) + _apply_multi("PastaPath", state_keys["f_pasta_path"]) + # (opcional) nome curto + _apply_multi("Pasta", state_keys["f_pasta"]) + _apply_multi("portaria", state_keys["f_portaria"]) + _apply_multi("tipo", state_keys["f_tipo"]) + _apply_multi("placa", state_keys["f_placa"]) + _apply_multi("Importancia", state_keys["f_importancia"]) + _apply_multi("Categoria", state_keys["f_categoria"]) + _apply_multi("liberacao_saida_responsavel", state_keys["f_resp_saida"]) + _apply_multi("liberacao_entrada_responsavel", state_keys["f_resp_entrada"]) + + # cliente (lista ou string) + cliente_sel = st.session_state[state_keys["f_cliente"]] + if cliente_sel: + if "cliente_lista" in df_f.columns: + df_f = df_f[df_f["cliente_lista"].apply(lambda lst: isinstance(lst, list) and any(c in lst for c in cliente_sel))] + elif "cliente" in df_f.columns: + df_f = df_f[df_f["cliente"].astype(str).isin(cliente_sel)] + + # lido + lido_sel = st.session_state[state_keys["f_lido"]] + if lido_sel and "Lido" in df_f.columns: + df_f = df_f[df_f["Lido"].astype(str).isin(lido_sel)] + + # status + status_sel = st.session_state[state_keys["f_status"]] + if status_sel and "status_lista" in df_f.columns: + df_f = df_f[df_f["status_lista"].apply(lambda lst: isinstance(lst, list) and any(s in lst for s in status_sel))] + + # flags + opt_saida = st.session_state[state_keys["f_saida_flag"]] + if opt_saida in ("Sim", "Não") and "liberacao_saida_flag" in df_f.columns: + df_f = df_f[df_f["liberacao_saida_flag"] == (opt_saida == "Sim")] + opt_entr = st.session_state[state_keys["f_entrada_flag"]] + if opt_entr in ("Sim", "Não") and "liberacao_entrada_flag" in df_f.columns: + df_f = df_f[df_f["liberacao_entrada_flag"] == (opt_entr == "Sim")] + + # numéricos + if st.session_state.get("__rng_anexos__") and "Anexos" in df_f.columns: + a0, a1 = st.session_state["__rng_anexos__"] + df_f = df_f[(df_f["Anexos"] >= a0) & (df_f["Anexos"] <= a1)] + if st.session_state.get("__rng_tamanho__") and "TamanhoKB" in df_f.columns: + s0, s1 = st.session_state["__rng_tamanho__"] + df_f = df_f[(df_f["TamanhoKB"] >= s0) & (df_f["TamanhoKB"] <= s1)] + + # igualdade + if st.session_state.get(state_keys["f_anexos_apply_equal"]) and "Anexos" in df_f.columns: + eq_val = st.session_state.get(state_keys["f_anexos_equal"]) + if eq_val is not None: + df_f = df_f[df_f["Anexos"] == int(eq_val)] + if st.session_state.get(state_keys["f_tamanho_apply_equal"]) and "TamanhoKB" in df_f.columns: + eq_sz = st.session_state.get(state_keys["f_tamanho_equal"]) + if eq_sz is not None: + df_f = df_f[df_f["TamanhoKB"].round(1) == round(float(eq_sz), 1)] + + # texto + txt = st.session_state[state_keys["f_assunto"]] + if txt and "Assunto" in df_f.columns: + df_f = df_f[df_f["Assunto"].astype(str).str.contains(txt.strip(), case=False, na=False)] + + cols_for_topn = st.session_state[state_keys["f_cols_topn"]] or [] + + total_final = len(df_f) + st.caption(f"🧮 **Diagnóstico**: antes dos filtros = {total_inicial} | depois dos filtros = {total_final}") + + return df_f, cols_for_topn + + possiveis_cols_init = [c for c in ["Pasta", "PastaPath", "portaria", "cliente", "cliente_lista", "tipo", "placa", + "Importancia", "Categoria", "Remetente", "Status_join", + "liberacao_saida_responsavel", "liberacao_entrada_responsavel"] if c in df_f.columns] + desired_default_init = st.session_state[state_keys["f_cols_topn"]] or ["cliente", "Status_join"] + default_cols_init = _sanitize_default(possiveis_cols_init, desired_default_init) + + st.caption(f"🧮 **Diagnóstico**: registros disponíveis (sem aplicar filtros) = {total_inicial}") + return df, default_cols_init + +# ============================== +# UI — módulo (com buffer/caching + UX simplificada) +# ============================== +def main(): + st.title("📧 Relatório • Outlook Desktop (Estruturado)") + st.caption("Escolha a Caixa (Entrada ou Itens Enviados), defina o período e extraia campos estruturados do e‑mail.") + st.markdown(_STYLES, unsafe_allow_html=True) + + # Buffer de dados + st.session_state.setdefault("__outlook_df__", None) + st.session_state.setdefault("__outlook_meta__", None) + + with st.expander("⚙️ Configurações", expanded=True): + with st.form("form_execucao_outlook"): + col_a, col_b, col_c = st.columns([1, 1, 1]) + dias = col_a.slider("Período (últimos N dias)", min_value=1, max_value=365, value=30) + filtro_remetente = col_b.text_input("Filtrar por remetente (opcional)", value="", placeholder='Ex.: "@fornecedor.com" ou "Fulano"') + extrair_campos = col_c.checkbox("Extrair campos do e‑mail (modelo padrão)", value=True) + + # Seletor simplificado + tipo_pasta = st.selectbox( + "Tipo de Caixa:", + ["📥 Caixa de Entrada", "📤 Itens Enviados"], + help="Escolha se deseja ler a Caixa de Entrada ou os Itens Enviados." + ) + + mapa_caminho = { + "📥 Caixa de Entrada": "Inbox", + "📤 Itens Enviados": "Sent Items" + } + pasta_base = mapa_caminho[tipo_pasta] + + subpasta_manual = st.text_input( + "Subpasta (opcional)", + value="", + placeholder="Ex.: Financeiro\\Operacional" + ) + + if subpasta_manual.strip(): + pasta_final = f"{pasta_base}\\{subpasta_manual.strip()}" + else: + pasta_final = pasta_base + + pastas_escolhidas = [pasta_final] + + # Botões + col_btn1, col_btn2, col_btn3, col_btn4 = st.columns([1, 1, 1, 1]) + submit_cache = col_btn1.form_submit_button("🔍 Gerar (com cache)") + submit_fresh = col_btn2.form_submit_button("⚡ Atualizar agora (sem cache)") + submit_clear = col_btn3.form_submit_button("🧹 Limpar cache") + submit_test = col_btn4.form_submit_button("🧪 Teste de conexão") + + # Ações pós-submit + if submit_clear: + try: + _cache_outlook_df.clear() + except Exception: + try: + st.cache_data.clear() + except Exception: + pass + st.session_state["__outlook_df__"] = None + st.session_state["__outlook_meta__"] = None + st.success("Cache limpo. Gere novamente os dados.") + + if submit_test: + try: + import win32com.client + pythoncom.CoInitialize() + ns = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI") + stores = ns.Folders.Count + inbox_def = ns.GetDefaultFolder(6) + sent_def = ns.GetDefaultFolder(5) + st.info(f"Stores detectadas: **{stores}** | Inbox padrão: **{inbox_def.Name}** | Sent: **{sent_def.Name}**") + if pastas_escolhidas: + for p in pastas_escolhidas: + try: + f = _get_folder_by_path_any_store(ns, p) + st.write(f"📁 {p} → '{f.Name}' itens: {f.Items.Count}") + except Exception as e: + st.warning(f"[Teste] Falha ao acessar '{p}': {e}") + except Exception as e: + st.error(f"[Teste] Erro ao conectar: {e}") + finally: + try: + pythoncom.CoUninitialize() + except Exception: + pass + + if submit_fresh: + if not pastas_escolhidas: + st.error("Selecione ao menos uma pasta.") + else: + with st.spinner("Lendo e-mails do Outlook (sem cache)..."): + df_fresh = _read_outlook_fresh(pastas_escolhidas, dias, filtro_remetente, extrair_campos) + st.session_state["__outlook_df__"] = df_fresh + st.session_state["__outlook_meta__"] = { + "pastas": list(pastas_escolhidas), + "dias": dias, + "filtro_remetente": filtro_remetente, + "extrair_campos": extrair_campos, + "loaded_at": datetime.now().strftime("%d/%m/%Y %H:%M"), + "source": "fresh" + } + + if submit_cache: + if not pastas_escolhidas: + st.error("Selecione ao menos uma pasta.") + else: + with st.spinner("Lendo e-mails do Outlook (com cache)..."): + df = _cache_outlook_df(tuple(pastas_escolhidas), dias, filtro_remetente, extrair_campos, _v=1) + st.session_state["__outlook_df__"] = df + st.session_state["__outlook_meta__"] = { + "pastas": list(pastas_escolhidas), + "dias": dias, + "filtro_remetente": filtro_remetente, + "extrair_campos": extrair_campos, + "loaded_at": datetime.now().strftime("%d/%m/%Y %H:%M"), + "source": "cache" + } + + # Usa o buffer se disponível + df_src = st.session_state.get("__outlook_df__") + meta = st.session_state.get("__outlook_meta__") + + if isinstance(df_src, pd.DataFrame): + origem = (meta or {}).get("source", "cache/buffer") + if df_src.empty: + st.info("Nenhum e-mail encontrado para os parâmetros informados. Use o botão 🧪 Teste de conexão para diagnosticar.") + return + + # 🎛️ Barra de Status + pastas_lbl = ", ".join(meta.get("pastas") or []) + extra_flag = "SIM" if meta.get("extrair_campos") else "NÃO" + st.markdown( + f""" +
+
+
📂 Pastas: {pastas_lbl}
+
🗓️ Dias: {meta.get('dias')}
+
🔍 Origem: {origem}
+
🧩 Extração: {extra_flag}
+
⏱️ Em: {meta.get('loaded_at')}
+
+
+ """, unsafe_allow_html=True + ) + + # Filtros dinâmicos + df_filtrado, cols_for_topn = _build_dynamic_filters(df_src) + + # Alias visual para colunas úteis + if "placa" in df_filtrado.columns and "Placa" not in df_filtrado.columns: + df_filtrado = df_filtrado.copy() + df_filtrado["Placa"] = df_filtrado["placa"] + if "cliente_lista" in df_filtrado.columns and "ClienteLista" not in df_filtrado.columns: + df_filtrado = df_filtrado.copy() + df_filtrado["ClienteLista"] = df_filtrado["cliente_lista"].apply(lambda lst: "; ".join(lst) if isinstance(lst, list) else "") + + # 🔖 Abas de visualização + tab_geral, tab_tabela, tab_kpis, tab_inds, tab_diag = st.tabs( + ["🧭 Visão Geral", "📄 Tabela", "📈 KPIs & Gráficos", "🏆 Indicadores (Top N)", "🛠️ Diagnóstico"] + ) + + with tab_geral: + # Mini-resumo + qtd = len(df_filtrado) + unicos_pastas = df_filtrado["PastaPath"].nunique() if "PastaPath" in df_filtrado.columns else df_filtrado["Pasta"].nunique() if "Pasta" in df_filtrado.columns else None + dt_min = pd.to_datetime(df_filtrado["RecebidoEm"], errors="coerce").min() + dt_max = pd.to_datetime(df_filtrado["RecebidoEm"], errors="coerce").max() + st.write(f"**Registros após filtros:** {qtd} " + + (f"• **Pastas (únicas)**: {unicos_pastas}" if unicos_pastas is not None else "") + + (f" • **Intervalo**: {dt_min.strftime('%d/%m/%Y %H:%M')} → {dt_max.strftime('%d/%m/%Y %H:%M')}" if pd.notna(dt_min) and pd.notna(dt_max) else "") + ) + + # Série temporal simples + if "RecebidoEm" in df_filtrado.columns: + try: + dts = pd.to_datetime(df_filtrado["RecebidoEm"], errors="coerce") + series = dts.dt.date.value_counts().sort_index() + fig = px.bar(series, x=series.index, y=series.values, labels={"x":"Data", "y":"Qtd"}, + title="Mensagens por dia", template="plotly_white") + fig.update_layout(height=360) + st.plotly_chart(fig, use_container_width=True) + except Exception: + pass + + # ============================== + # ✅ ABA TABELA — Cliente único + Data/Hora + ícones + # ============================== + with tab_tabela: + df_show = _build_table_view_unique_client(df_filtrado) + + # 🎛️ Configurações de colunas (tipos e rótulos) + colcfg = {} + + # Data e Hora: tenta usar tipos nativos; fallback para texto + if "Data" in df_show.columns: + try: + colcfg["Data"] = st.column_config.DateColumn("Data", format="DD/MM/YYYY") + except Exception: + colcfg["Data"] = st.column_config.TextColumn("Data") + + if "Hora" in df_show.columns: + # Alguns builds do Streamlit não possuem TimeColumn — fallback automático + try: + colcfg["Hora"] = st.column_config.TimeColumn("Hora", format="HH:mm") + except Exception: + colcfg["Hora"] = st.column_config.TextColumn("Hora") + + if "Cliente" in df_show.columns: + colcfg["Cliente"] = st.column_config.TextColumn("Cliente") + + if "Placa" in df_show.columns: + colcfg["Placa"] = st.column_config.TextColumn("Placa") + + if "tipo" in df_show.columns: + colcfg["tipo"] = st.column_config.TextColumn("Tipo") + + if "Status_join" in df_show.columns: + colcfg["Status_join"] = st.column_config.TextColumn("Status") + + if "Anexos" in df_show.columns: + colcfg["Anexos"] = st.column_config.NumberColumn("Anexos", format="%d") + + if "TamanhoKB" in df_show.columns: + colcfg["TamanhoKB"] = st.column_config.NumberColumn("Tamanho (KB)", format="%.1f") + + if "Remetente" in df_show.columns: + colcfg["Remetente"] = st.column_config.TextColumn("Remetente") + + if "PastaPath" in df_show.columns: + colcfg["PastaPath"] = st.column_config.TextColumn("Caminho da Pasta") + + if "Pasta" in df_show.columns: + colcfg["Pasta"] = st.column_config.TextColumn("Pasta") + + if "portaria" in df_show.columns: + colcfg["portaria"] = st.column_config.TextColumn("Portaria") + + # Colunas visuais + if "📎" in df_show.columns: + colcfg["📎"] = st.column_config.TextColumn("Anexo") + if "🔔" in df_show.columns: + colcfg["🔔"] = st.column_config.TextColumn("Importância") + if "👁️" in df_show.columns: + colcfg["👁️"] = st.column_config.TextColumn("Lido") + + st.dataframe( + df_show, + use_container_width=True, + hide_index=True, + column_config=colcfg, + height=560 + ) + + st.caption("🔎 A Tabela está em visão **por cliente** (uma linha por cliente) e com **Data** e **Hora** separadas.") + + with tab_kpis: + _render_kpis(df_filtrado) + + with tab_inds: + _render_indicators_custom(df_filtrado, dt_col_name="RecebidoEm", cols_for_topn=cols_for_topn, topn_default=10) + + with tab_diag: + st.write("**Colunas disponíveis**:", list(df_src.columns)) + st.write("**Linhas (antes filtros)**:", len(df_src)) + st.write("**Linhas (após filtros)**:", len(df_filtrado)) + if "PastaPath" in df_src.columns: + st.write("**Pastas distintas**:", df_src["PastaPath"].nunique()) + if "__corpo__" in df_src.columns: + st.text_area("Amostra de corpo (debug, 1ª linha):", value=str(df_src["__corpo__"].dropna().head(1).values[0]) if df_src["__corpo__"].dropna().any() else "", height=120) + + # ============================== + # ✅ Downloads — exporta a mesma visão da Tabela (explodida + Data/Hora) + # ============================== + base_name = f"relatorio_outlook_desktop_{date.today()}" + df_to_export = _build_table_view_unique_client(df_filtrado) + _build_downloads(df_to_export, base_name=base_name) + + # Auditoria + if _HAS_AUDIT and meta: + try: + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"Relatório Outlook (pastas={len(meta['pastas'])}, dias={meta['dias']}, extrair={origem}) — filtros aplicados", + tabela="outlook_relatorio", + registro_id=None + ) + except Exception: + pass + + else: + st.info("👉 Selecione a caixa (Entrada/Saída), opcionalmente uma subpasta, e clique em **🔍 Gerar (com cache)** ou **⚡ Atualizar agora (sem cache)**. Use **🧪 Teste de conexão** se vier vazio.") + +# if __name__ == "__main__": +# main() diff --git a/passenger_wsgi.py b/passenger_wsgi.py new file mode 100644 index 0000000000000000000000000000000000000000..ee69ee59a130ec8887a763c99f4a441de5d4b967 --- /dev/null +++ b/passenger_wsgi.py @@ -0,0 +1,13 @@ +import os +import sys + +# Ensure the application root is in the import path +APP_ROOT = os.path.dirname(os.path.abspath(__file__)) +if APP_ROOT not in sys.path: + sys.path.insert(0, APP_ROOT) + +# Import the starter that launches Streamlit as a subprocess +import start_streamlit # noqa: F401 + +# Expose a minimal WSGI callable so Passenger is satisfied +application = start_streamlit.application \ No newline at end of file diff --git a/quiz.py b/quiz.py new file mode 100644 index 0000000000000000000000000000000000000000..16635df1a8200a626351ed589afe5363cb2fb74f --- /dev/null +++ b/quiz.py @@ -0,0 +1,188 @@ + +import streamlit as st +import random +from banco import SessionLocal +from models import QuizPontuacao, QuizPergunta, QuizResposta +from datetime import datetime +from utils_auditoria import registrar_log + +# ========================== +# CARREGAR PERGUNTAS DO BANCO +# ========================== +def carregar_perguntas_do_banco(): + db = SessionLocal() + perguntas = [] + try: + q_perguntas = db.query(QuizPergunta).filter(QuizPergunta.ativo == True).all() + for p in q_perguntas: + respostas = db.query(QuizResposta).filter(QuizResposta.pergunta_id == p.id).all() + if len(respostas) < 3: + continue # garante pelo menos 3 opções + opcoes = [r.texto for r in respostas] + correta_idx = next((i for i, r in enumerate(respostas) if r.correta), 0) + perguntas.append({ + "pergunta": p.pergunta, + "opcoes": opcoes, + "resposta": correta_idx + }) + finally: + db.close() + return perguntas + +# ========================== +# SALVAR PONTUAÇÃO +# ========================== +def salvar_pontuacao(usuario, pontos): + db = SessionLocal() + try: + registro = QuizPontuacao(usuario=usuario, pontos=pontos, data=datetime.now()) + db.add(registro) + db.commit() + db.refresh(registro) # garante que registro.id está populado + registrar_log(usuario=usuario, acao=f"Finalizou quiz com {pontos} pontos", tabela="quiz_pontuacao", registro_id=registro.id) + return registro.id + finally: + db.close() + +# ========================== +# EXCLUIR PONTUAÇÃO (quando rejogar) +# ========================== +def excluir_pontuacao(registro_id): + if not registro_id: + return + db = SessionLocal() + try: + registro = db.query(QuizPontuacao).filter(QuizPontuacao.id == registro_id).first() + if registro: + db.delete(registro) + db.commit() + # (Opcional) registro de auditoria da exclusão, se desejar: + # registrar_log(usuario=registro.usuario, acao=f"Excluiu pontuação da tentativa anterior ({registro.pontos} pontos)", tabela="quiz_pontuacao", registro_id=registro_id) + finally: + db.close() + +# ========================== +# VERIFICAR EXISTÊNCIA DE REGISTRO +# ========================== +def registro_existe(registro_id): + """Retorna True se o registro de pontuação ainda existe na base.""" + if not registro_id: + return False + db = SessionLocal() + try: + return db.query(QuizPontuacao.id).filter(QuizPontuacao.id == registro_id).first() is not None + finally: + db.close() + +# ========================== +# FUNÇÃO PRINCIPAL +# ========================== +def main(): + st.title("🧠 Quiz – FPSO | WMS | IFS") + + usuario = st.session_state.get("usuario") + if not usuario: + st.error("Usuário não identificado.") + return + + # Inicialização de estado + if "quiz_indice" not in st.session_state: + st.session_state.quiz_indice = 0 + if "quiz_pontos" not in st.session_state: + st.session_state.quiz_pontos = 0 + if "quiz_salvo" not in st.session_state: + st.session_state.quiz_salvo = False + # Guarda o ID do registro salvo para permitir exclusão ao rejogar + if "quiz_registro_id" not in st.session_state: + st.session_state.quiz_registro_id = None + + # Carregar perguntas + TODAS_PERGUNTAS = carregar_perguntas_do_banco() + NUM_PERGUNTAS_RODADA = 2 + k = min(NUM_PERGUNTAS_RODADA, len(TODAS_PERGUNTAS)) + + # Caso não haja perguntas, permitir voltar ao sistema + if k == 0: + st.warning("⚠️ Nenhuma pergunta ativa disponível. Cadastre perguntas para jogar.") + if st.button("⬅️ Voltar ao sistema"): + st.session_state.quiz_verificado = True + st.session_state.quiz_pendente = False + st.rerun() + return + + # Sorteio de perguntas + if "perguntas_rodada" not in st.session_state: + st.session_state.perguntas_rodada = random.sample(TODAS_PERGUNTAS, k=k) + + PERGUNTAS = st.session_state.perguntas_rodada + indice = st.session_state.quiz_indice + + # Final do quiz + if indice >= len(PERGUNTAS): + st.success("🎉 Quiz finalizado!") + st.metric("🏆 Pontuação", st.session_state.quiz_pontos) + + # --- Novo: Se a app acha que já salvou, mas o ranking foi resetado, permita salvar de novo --- + if st.session_state.quiz_salvo and st.session_state.quiz_registro_id and not registro_existe(st.session_state.quiz_registro_id): + # O ranking foi resetado e o registro sumiu => reabrir para salvar novamente + st.info("ℹ️ O ranking foi resetado. Você pode registrar esta pontuação novamente.") + st.session_state.quiz_salvo = False + st.session_state.quiz_registro_id = None + + # Salvar a pontuação apenas uma vez e guardar o ID para potencial exclusão ao rejogar + if not st.session_state.quiz_salvo: + registro_id = salvar_pontuacao(usuario, st.session_state.quiz_pontos) + st.session_state.quiz_salvo = True + st.session_state.quiz_registro_id = registro_id + st.success("✅ Pontuação registrada com sucesso.") + + col1, col2 = st.columns(2) + if col1.button("🔄 Jogar novamente"): + # Ao rejogar: remover a pontuação anterior do ranking (não contar tentativa anterior) + if registro_existe(st.session_state.quiz_registro_id): + excluir_pontuacao(st.session_state.quiz_registro_id) + + # Resetar estado para nova rodada + st.session_state.quiz_indice = 0 + st.session_state.quiz_pontos = 0 + st.session_state.quiz_salvo = False + st.session_state.quiz_registro_id = None + st.session_state.perguntas_rodada = random.sample(TODAS_PERGUNTAS, k=min(NUM_PERGUNTAS_RODADA, len(TODAS_PERGUNTAS))) + st.rerun() + + if col2.button("⬅️ Voltar ao sistema"): + st.session_state.quiz_verificado = True + st.session_state.quiz_pendente = False + st.rerun() + return + + # Pergunta atual + pergunta_atual = PERGUNTAS[indice] + + st.progress((indice + 1) / len(PERGUNTAS)) + st.caption(f"Pergunta {indice + 1} de {len(PERGUNTAS)}") + + with st.form(key=f"form_pergunta_{indice}"): + st.write(f"**{pergunta_atual['pergunta']}**") + resposta = st.radio("Escolha uma opção:", pergunta_atual["opcoes"]) + enviar = st.form_submit_button("Responder") + + if enviar: + correta = pergunta_atual["opcoes"][pergunta_atual["resposta"]] + if resposta == correta: + st.success("✅ Resposta correta!") + st.session_state.quiz_pontos += 10 + else: + st.error(f"❌ Resposta incorreta. Correta: {correta}") + st.session_state.quiz_indice += 1 + st.rerun() + +if __name__ == "__main__": + main() + + + + + + + diff --git a/quiz_admin.py b/quiz_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..ebe5981116cd419fc09a9a38c199c96b787b8401 --- /dev/null +++ b/quiz_admin.py @@ -0,0 +1,72 @@ + +import streamlit as st +from banco import SessionLocal +from models import QuizPergunta, QuizResposta, QuizPontuacao # ⬅️ importa QuizPontuacao + +def main(): + st.title("🛠️ Quiz Admin") + + db = SessionLocal() + + # ========================== + # FORMULÁRIO: NOVA PERGUNTA + # ========================== + with st.form("nova_pergunta"): + pergunta = st.text_input("Pergunta") + + r1 = st.text_input("Resposta A") + r2 = st.text_input("Resposta B") + r3 = st.text_input("Resposta C") + correta = st.selectbox("Resposta Correta", ["A", "B", "C"]) + + salvar = st.form_submit_button("Salvar") + + if salvar: + p = QuizPergunta(pergunta=pergunta) + db.add(p) + db.commit() + + respostas = [ + QuizResposta(pergunta_id=p.id, texto=r1, correta=(correta == "A")), + QuizResposta(pergunta_id=p.id, texto=r2, correta=(correta == "B")), + QuizResposta(pergunta_id=p.id, texto=r3, correta=(correta == "C")), + ] + + db.add_all(respostas) + db.commit() + + st.success("Pergunta cadastrada com sucesso!") + + st.divider() + + # ========================== + # RESETAR RANKING (ZONA DE RISCO) + # ========================== + with st.expander("⚠️ Zona de risco: Resetar Ranking", expanded=False): + st.warning( + "Esta ação irá **apagar todas as pontuações** registradas no ranking (tabela `quiz_pontuacao`). " + "Não há como desfazer. Use com cuidado." + ) + + confirmar = st.checkbox("Eu entendo o risco e desejo prosseguir.") + codigo_confirmacao = st.text_input("Digite 'RESETAR' para confirmar:", value="", max_chars=7) + + # Botão de reset só habilita quando confirmação dupla estiver ok + resetar_habilitado = confirmar and (codigo_confirmacao.strip().upper() == "RESETAR") + + if st.button("🗑️ Resetar Ranking", disabled=not resetar_habilitado, type="primary"): + try: + # Exclusão direta de todos os registros de pontuação + deletados = db.query(QuizPontuacao).delete(synchronize_session=False) + db.commit() + st.success(f"Ranking resetado com sucesso. Registros removidos: {deletados}.") + except Exception as e: + db.rollback() + st.error(f"Falha ao resetar o ranking: {e}") + + # Fecha a sessão do banco ao final + try: + db.close() + except: + pass + diff --git a/ranking.py b/ranking.py new file mode 100644 index 0000000000000000000000000000000000000000..0c5d035805a81f79843523cc61d4359757d1ccc1 --- /dev/null +++ b/ranking.py @@ -0,0 +1,116 @@ +import streamlit as st +import pandas as pd +from datetime import datetime, timedelta +from sqlalchemy import func +from banco import SessionLocal +from models import QuizPontuacao + + +# ========================== +# REGRAS DE BADGE +# ========================== +def definir_badge(pontos): + if pontos >= 1000: + return "💎 Diamante" + elif pontos >= 500: + return "🥇 Ouro" + elif pontos >= 200: + return "🥈 Prata" + else: + return "🥉 Bronze" + + +# ========================== +# FUNÇÃO PRINCIPAL +# ========================== +def main(): + st.title("🏆 Ranking do Quiz") + st.divider() + + db = SessionLocal() + + # ========================== + # FILTRO DE PERÍODO + # ========================== + periodo = st.selectbox( + "📅 Período do Ranking", + ["Geral", "Últimos 7 dias", "Mês atual"] + ) + + hoje = datetime.now() + + if periodo == "Últimos 7 dias": + data_inicio = hoje - timedelta(days=7) + elif periodo == "Mês atual": + data_inicio = hoje.replace(day=1) + else: + data_inicio = None + + # ========================== + # CONSULTA + # ========================== + query = db.query( + QuizPontuacao.usuario, + func.sum(QuizPontuacao.pontos).label("total_pontos") + ) + + if data_inicio: + query = query.filter(QuizPontuacao.data >= data_inicio) + + ranking = ( + query + .group_by(QuizPontuacao.usuario) + .order_by(func.sum(QuizPontuacao.pontos).desc()) + .all() + ) + + if not ranking: + st.info("📭 Nenhuma pontuação encontrada para o período selecionado.") + return + + # ========================== + # DATAFRAME + # ========================== + dados = [] + for posicao, r in enumerate(ranking, start=1): + dados.append({ + "Posição": posicao, + "Usuário": r.usuario, + "Pontos": r.total_pontos, + "Badge": definir_badge(r.total_pontos) + }) + + df = pd.DataFrame(dados) + + # ========================== + # EXIBIÇÃO + # ========================== + st.subheader("📊 Classificação") + + st.dataframe( + df, + use_container_width=True, + hide_index=True + ) + + # ========================== + # EXPORTAÇÃO EXCEL + # ========================== + st.divider() + st.subheader("⬇️ Exportar Ranking") + + nome_arquivo = f"ranking_quiz_{periodo.replace(' ', '_').lower()}.xlsx" + + buffer = pd.ExcelWriter(nome_arquivo, engine="xlsxwriter") + df.to_excel(buffer, index=False, sheet_name="Ranking") + buffer.close() + + with open(nome_arquivo, "rb") as file: + st.download_button( + label="📥 Baixar ranking em Excel", + data=file, + file_name=nome_arquivo, + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + + db.close() diff --git a/ranking_quiz_geral.xlsx b/ranking_quiz_geral.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..a46ab42bd3800546822884df2d3c85889ea74bc0 Binary files /dev/null and b/ranking_quiz_geral.xlsx differ diff --git a/recebimento.py b/recebimento.py new file mode 100644 index 0000000000000000000000000000000000000000..b1f0eafcbe7633e4eca980e879c062bebfad649e --- /dev/null +++ b/recebimento.py @@ -0,0 +1,2070 @@ +# -*- coding: utf-8 -*- +import io +import re +import secrets +import hashlib +import unicodedata +from datetime import date, datetime, time, timedelta +from typing import Any, Dict, List, Optional, Tuple, Callable + +import pandas as pd +import streamlit as st + +# Altair (para gráficos com linhas/metas). Fallback automático se não existir. +try: + import altair as alt + ALT_AVAILABLE = True +except Exception: + ALT_AVAILABLE = False + +from banco import SessionLocal +from models import RecebimentoRegistro + + +# ========================================================== +# Sessão de banco (compatível com seu app) +# ========================================================== +def _get_db_session(): + try: + from db_router import get_session_for_current_db + return get_session_for_current_db() + except Exception: + return SessionLocal() + + +# ========================================================== +# Utilitários de exportação (CSV/Excel) — usar em todo o app +# ========================================================== +def _df_to_excel_bytes(df: pd.DataFrame, sheet_name: str = "Dados") -> bytes: + """ + Converte um DataFrame em bytes Excel (.xlsx) usando openpyxl. + Retorna b"" se o engine não estiver disponível. + """ + bio = io.BytesIO() + try: + with pd.ExcelWriter(bio, engine="openpyxl") as xw: + df.to_excel(xw, index=False, sheet_name=sheet_name) + bio.seek(0) + return bio.getvalue() + except Exception: + # Fallback: retorna vazio para o chamador desabilitar o botão + return b"" + + +def _download_buttons(df: pd.DataFrame, filename_prefix: str): + """ + Renderiza dois botões de download (CSV e Excel) para um DataFrame. + Usa _df_to_excel_bytes e desabilita o botão de Excel se openpyxl indisponível. + """ + c1, c2 = st.columns(2) + csv_bytes = df.to_csv(index=False).encode("utf-8-sig") + c1.download_button("⬇️ Baixar CSV", data=csv_bytes, file_name=f"{filename_prefix}.csv") + + xlsx = _df_to_excel_bytes(df) + if xlsx: + c2.download_button("⬇️ Baixar Excel", data=xlsx, file_name=f"{filename_prefix}.xlsx") + else: + c2.caption("Excel indisponível (openpyxl ausente).") + + +# ========================================================== +# Conversores padronizados +# ========================================================== +def conv_bool(v): + if v is None: + return None + s = str(v).strip().upper() + if s in ("SIM", "S", "TRUE", "1"): + return True + if s in ("NÃO", "NAO", "N", "FALSE", "0"): + return False + return None # N/A ou vazio + + +def conv_date(v): + if v is None or v == "": + return None + if isinstance(v, date) and not isinstance(v, datetime): + return v + try: + return pd.to_datetime(v, dayfirst=True, errors="coerce").date() + except Exception: + return None + + +def _normalize_hms_str(s: str) -> str: + """ + Normaliza strings flexíveis para HH:MM:SS. + """ + s0 = str(s).strip() + if not s0: + return "" + + # troca separadores incomuns por ':' + s1 = re.sub(r"[hH]", ":", s0) + s1 = re.sub(r"[mM]", ":", s1) + s1 = re.sub(r"[sS]", ":", s1) + s1 = re.sub(r"[.;,\s]+", ":", s1) + + if re.fullmatch(r"\d{1,6}", s1): + n = s1 + if len(n) <= 2: # "8" -> HH + hh = int(n) + return f"{hh:02d}:00:00" + elif len(n) in (3, 4): # "815" / "0815" -> HHMM + hh = int(n[:-2]); mm = int(n[-2:]) + return f"{hh:02d}:{mm:02d}:00" + elif len(n) in (5, 6): # "81530" / "081530" -> HHMMSS + hh = int(n[:-4]); mm = int(n[-4:-2]); ss = int(n[-2:]) + return f"{hh:02d}:{mm:02d}:{ss:02d}" + + parts = [p for p in s1.split(":") if p != ""] + if 1 <= len(parts) <= 3 and all(re.fullmatch(r"\d{1,2}", p) for p in parts): + hh = int(parts[0]) + mm = int(parts[1]) if len(parts) >= 2 else 0 + ss = int(parts[2]) if len(parts) == 3 else 0 + mm += ss // 60; ss = ss % 60 + hh += mm // 60; mm = mm % 60 + hh = hh % 24 + return f"{hh:02d}:{mm:02d}:{ss:02d}" + + # fallback pandas + try: + t = pd.to_datetime(s0, errors="coerce") + if pd.isna(t): + return "" + return t.strftime("%H:%M:%S") + except Exception: + return "" + + +def conv_excel_time(v): + """ + Converte variados formatos para 'HH:MM:SS'. + """ + if v is None or v == "": + return "" + if isinstance(v, str): + return _normalize_hms_str(v) + if isinstance(v, (int, float)): + try: + total_seconds = int(round(float(v) * 86400)) + h = (total_seconds // 3600) % 24 + m = (total_seconds % 3600) // 60 + s = total_seconds % 60 + return f"{h:02d}:{m:02d}:{s:02d}" + except Exception: + return "" + if isinstance(v, time): + return v.strftime("%H:%M:%S") + if isinstance(v, datetime): + return v.strftime("%H:%M:%S") + return "" + + +# ========================================================== +# Modelo oficial: 37 colunas EXATAS (sem ID) +# ========================================================== +OFFICIAL_COLUMNS: List[str] = [ + "HOR. DE CHEGADA NA PORTARIA", + "HOR. DE CHEGADA NO IFS", + "HOR. DE SAÍDA DO IFS/WMS", + "DATA", + "PLACA VEÍCULO", + "TRANSPORTADORA", + "PO", + "INCOTERMS", + "Qtd. SKU", + "NOTA FISCAL", + "FORNECEDOR", + "QUIMICOS", + "FDS", + "NATUREZA DA OPERAÇÃO", + "TIPO DE OPERAÇÃO", + "BARCO", + "DIVERGENCIA", + "IFS", + "WMS", + "FOTOGRAFIA", + "ENTREGA", + "PROJETO", + "REPETRO", + "HOR. LIBERAÇÃO PARA OPERAÇÃO", + "HOR. DE CHEGADA NA OPERAÇÃO", + "HOR. DE SAÍDA DA OPERAÇÃO", + "DATA DE EMISSÃO", + "APPROVED?", + "GOOD RECEIPT", + "HORA DE RETORNO DA OPERAÇÃO", + "DIVERGÊNCIA RECEBIMENTO", + "HORA DA LIBERAÇÃO DO MOTORISTA", + "QUALIDADE", + "DIVERGÊNCIA QUALIDADE", + "OBSERVAÇÃO", + "AGENDAMENTO", + "RESPONSÁVEL", +] + +# Colunas de horário para normalização visual +TIME_COLUMNS = [ + "HOR. DE CHEGADA NA PORTARIA", + "HOR. DE CHEGADA NO IFS", + "HOR. DE SAÍDA DO IFS/WMS", + "HOR. LIBERAÇÃO PARA OPERAÇÃO", + "HOR. DE CHEGADA NA OPERAÇÃO", + "HOR. DE SAÍDA DA OPERAÇÃO", + "HORA DE RETORNO DA OPERAÇÃO", + "HORA DA LIBERAÇÃO DO MOTORISTA", +] + +# Colunas opcionais aceitas +OPTIONAL_COLUMNS: List[str] = ["ID", "P.O", "PN", "LOT BATCH"] + +# Mapeamento Excel → campos do modelo +COLUMN_MAP: Dict[str, Tuple[str, Optional[Any]]] = { + "ID": ("id_planilha", lambda v: int(v) if str(v).strip().isdigit() else None), + + "HOR. DE CHEGADA NA PORTARIA": ("hora_chegada_portaria", conv_excel_time), + "HOR. DE CHEGADA NO IFS": ("hora_chegada_ifs", conv_excel_time), + "HOR. DE SAÍDA DO IFS/WMS": ("hora_saida_ifs_wms", conv_excel_time), + "DATA": ("data", conv_date), + "PLACA VEÍCULO": ("placa_veiculo", None), + "TRANSPORTADORA": ("transportadora", None), + "PO": ("po", None), + "INCOTERMS": ("incoterms", None), + "Qtd. SKU": ("qtd_sku", lambda v: int(v) if str(v).strip().isdigit() else None), + "NOTA FISCAL": ("nota_fiscal", None), + "FORNECEDOR": ("fornecedor", None), + "QUIMICOS": ("quimicos", conv_bool), + "FDS": ("fds", conv_bool), + "NATUREZA DA OPERAÇÃO": ("natureza_operacao", None), + "TIPO DE OPERAÇÃO": ("tipo_operacao", None), + "BARCO": ("barco", None), + + "DIVERGENCIA": ("divergencia", None), + "NF DIVERGENTE": ("divergencia", None), + + "IFS": ("ifs", None), + "WMS": ("wms", None), + "FOTOGRAFIA": ("fotografia", None), + "ENTREGA": ("entrega", None), + "PROJETO": ("projeto", None), + "REPETRO": ("repetro", conv_bool), + "HOR. LIBERAÇÃO PARA OPERAÇÃO": ("hora_liberacao_operacao", conv_excel_time), + "HOR. DE CHEGADA NA OPERAÇÃO": ("hora_chegada_operacao", conv_excel_time), + "HOR. DE SAÍDA DA OPERAÇÃO": ("hora_saida_operacao", conv_excel_time), + "DATA DE EMISSÃO": ("data_emissao", conv_date), + "APPROVED?": ("aprovado", conv_bool), + "GOOD RECEIPT": ("good_receipt", None), + "HORA DE RETORNO DA OPERAÇÃO": ("hora_retorno_operacao", conv_excel_time), + "DIVERGÊNCIA RECEBIMENTO": ("divergencia_recebimento", None), + "HORA DA LIBERAÇÃO DO MOTORISTA": ("hora_liberacao_motorista", conv_excel_time), + "QUALIDADE": ("qualidade", None), + "DIVERGÊNCIA QUALIDADE": ("divergencia_qualidade", None), + "OBSERVAÇÃO": ("observacao", None), + "AGENDAMENTO": ("agendamento", None), + "RESPONSÁVEL": ("responsavel", None), + + "P.O": ("po_alt", None), + "PN": ("pn", None), + "LOT BATCH": ("lot_batch", None), +} + +IGNORED_COLS = {"ID.PO2", "Unnamed: 1", "Unnamed: 40", "Unnamed: 41", "Unnamed: 42"} + + +# ========================================================== +# Funções de banco / ID automático +# ========================================================== +def _get_max_id_planilha() -> int: + db = _get_db_session() + try: + res = db.query(RecebimentoRegistro.id_planilha).order_by(RecebimentoRegistro.id_planilha.desc()).first() + return int(res[0]) if res and res[0] is not None else 0 + finally: + db.close() + + +def _next_id_planilha() -> int: + return _get_max_id_planilha() + 1 + + +def get_ultimo() -> Optional[RecebimentoRegistro]: + db = _get_db_session() + try: + return db.query(RecebimentoRegistro).order_by(RecebimentoRegistro.id.desc()).first() + finally: + db.close() + + +def sugestao_defaults() -> Dict[str, Any]: + u = get_ultimo() + if not u: + return {} + return { + "transportadora": u.transportadora, + "incoterms": u.incoterms, + "natureza_operacao": u.natureza_operacao, + "tipo_operacao": u.tipo_operacao, + "barco": u.barco, + "ifs": u.ifs, + "wms": u.wms, + "entrega": u.entrega, + "projeto": u.projeto, + "qualidade": u.qualidade, + "agendamento": u.agendamento, + "responsavel": u.responsavel, + } + + +# ========================================================== +# Helpers de perfil/admin +# ========================================================== +def _is_admin() -> bool: + return (st.session_state.get("perfil") or "").lower() == "admin" + + +# ========================================================== +# PIN (Admin) — com key_prefix para evitar IDs duplicados +# ========================================================== +PIN_KEY = "__PIN_RECEBIMENTO__" + +def _pin_info(): + return st.session_state.get(PIN_KEY, None) + +def _pin_is_valid() -> bool: + info = _pin_info() + if not info: + return False + if datetime.utcnow() >= info["exp"]: + st.session_state.pop("__PIN_OK__", None) + return False + return True + +def admin_pin_area(key_prefix: str = "__pin_admin__"): + """ + Renderiza a área de PIN somente se perfil == 'admin'. + Para não-admin: não renderiza nada. + """ + if not _is_admin(): + return + + with st.expander("🔐 Configurar PIN de edição (somente admin)", expanded=False): + col1, col2, col3 = st.columns([1, 1, 2]) + ttl_min = col1.number_input( + "Validade (min)", + min_value=1, max_value=120, value=15, step=1, + key=f"{key_prefix}__ttl_min__" + ) + if col2.button("Gerar PIN automático", key=f"{key_prefix}__btn_pin__"): + pin = f"{secrets.randbelow(10**6):06d}" + exp = datetime.utcnow() + timedelta(minutes=int(ttl_min)) + st.session_state[PIN_KEY] = {"pin": pin, "exp": exp} + st.session_state.pop("__PIN_OK__", None) + st.success(f"PIN gerado: {pin} (expira em {ttl_min} min)") + info = _pin_info() + if info: + restante = int((info["exp"] - datetime.utcnow()).total_seconds()) + mins = max(0, restante // 60) + secs = max(0, restante % 60) + col3.info(f"PIN atual: **{info['pin']}** | Expira em **{mins:02d}:{secs:02d}** (UTC)") + +def validar_pin(key_prefix: str = "__pin_val__") -> bool: + """ + Para admin: renderiza campo de PIN e valida. + Para não-admin: não renderiza nada e retorna False. + """ + if not _is_admin(): + return False + + if not _pin_is_valid(): + st.error("PIN ausente ou expirado. Solicite ao Admin um novo PIN.") + return False + if not st.session_state.get("__PIN_OK__", False): + entrada = st.text_input("Digite o PIN", type="password", key=f"{key_prefix}__pin_in__") + if st.button("Validar PIN", key=f"{key_prefix}__btn_validar_pin__"): + info = _pin_info() + if info and entrada == info["pin"] and _pin_is_valid(): + st.session_state["__PIN_OK__"] = True + st.success("PIN validado!") + else: + st.error("PIN incorreto ou expirado.") + return st.session_state.get("__PIN_OK__", False) + + +# ========================================================== +# Login de Admin + Reset do banco +# ========================================================== +def admin_login_area() -> bool: + """ + Exibe um formulário de login de admin (usuário/senha) usando st.secrets. + Se as credenciais baterem, seta perfil='admin' e retorna True. + """ + if _is_admin(): + st.success(f"Logado como admin: {st.session_state.get('usuario', 'admin')}") + colA, colB = st.columns([1, 1]) + if colA.button("Sair da sessão admin", key="__btn_logout_admin__"): + st.session_state.pop("perfil", None) + st.session_state.pop("__PIN_OK__", None) + st.rerun() + return True + + admin_user = st.secrets.get("ADMIN_USER", None) + admin_pass = st.secrets.get("ADMIN_PASS", None) + if not admin_user or not admin_pass: + with st.expander("Como configurar o login de admin", expanded=False): + st.info( + "Defina as credenciais em `.streamlit/secrets.toml`:\n\n" + "ADMIN_USER = \"admin\"\n" + "ADMIN_PASS = \"sua_senha_forte\"\n" + ) + + st.subheader("🔐 Login de Administrador") + with st.form("admin_login_form", clear_on_submit=False): + u = st.text_input("Usuário admin", placeholder="ex.: admin", key="__admin_user__") + p = st.text_input("Senha admin", type="password", placeholder="********", key="__admin_pass__") + ok = st.form_submit_button("Entrar", type="primary") + + if ok: + admin_user = st.secrets.get("ADMIN_USER", "") + admin_pass = st.secrets.get("ADMIN_PASS", "") + if u == admin_user and p == admin_pass and u != "" and p != "": + st.session_state["perfil"] = "admin" + st.session_state["usuario"] = u + st.success("Login admin realizado com sucesso.") + st.rerun() + else: + st.error("Credenciais inválidas.") + return False + + return _is_admin() + + +def _get_db(): + return _get_db_session() + + +def _fetch_all_recebimentos_df() -> pd.DataFrame: + """ + Retorna todos os registros de RecebimentoRegistro em DataFrame (snake_case), + útil para backup antes do reset. + """ + db = _get_db() + try: + regs = ( + db.query(RecebimentoRegistro) + .order_by(RecebimentoRegistro.created_at.asc()) + .all() + ) + if not regs: + return pd.DataFrame() + return pd.DataFrame([{c.name: getattr(r, c.name) for c in r.__table__.columns} for r in regs]) + finally: + db.close() + + +def reset_recebimento_registros() -> Tuple[int, Optional[str]]: + """ + Apaga TODOS os registros de RecebimentoRegistro. + Retorna (qtd_apagados, erro_str_ou_None). + """ + db = _get_db() + try: + qtd = db.query(RecebimentoRegistro).count() + db.query(RecebimentoRegistro).delete(synchronize_session=False) + db.commit() + return qtd, None + except Exception as e: + db.rollback() + return 0, str(e) + finally: + db.close() + + +# ========================================================== +# Deduplicação por conteúdo (chave única) +# ========================================================== +UNIQUE_KEY_FIELDS = [ + "data", "nota_fiscal", "fornecedor", "po", "pn", "lot_batch", "placa_veiculo" +] + +def _make_unique_key_from_values(vals: Dict[str, Any]) -> Optional[str]: + parts = [] + all_empty = True + for f in UNIQUE_KEY_FIELDS: + v = vals.get(f) + if f == "data": + d = conv_date(v) + sv = d.isoformat() if d else "" + else: + sv = (str(v).strip().upper() if v is not None else "") + if sv != "": + all_empty = False + parts.append(sv) + if all_empty: + return None + return "|".join(parts) + +def _fetch_existing_unique_keys() -> set: + """Busca no banco os campos necessários e monta o set de chaves únicas existentes.""" + db = _get_db() + try: + rows = db.query( + RecebimentoRegistro.data, + RecebimentoRegistro.nota_fiscal, + RecebimentoRegistro.fornecedor, + RecebimentoRegistro.po, + RecebimentoRegistro.pn, + RecebimentoRegistro.lot_batch, + RecebimentoRegistro.placa_veiculo, + ).all() + keys = set() + for r in rows: + vals = { + "data": r[0], + "nota_fiscal": r[1], + "fornecedor": r[2], + "po": r[3], + "pn": r[4], + "lot_batch": r[5], + "placa_veiculo": r[6], + } + k = _make_unique_key_from_values(vals) + if k: + keys.add(k) + return keys + finally: + db.close() + + +# ========================================================== +# Sanitização ciente por campo +# ========================================================== +DATE_FIELDS = {"data", "data_emissao"} +DATETIME_FIELDS = {"created_at", "updated_at"} +TIME_FIELDS = { + "hora_chegada_portaria", + "hora_chegada_ifs", + "hora_saida_ifs_wms", + "hora_liberacao_operacao", + "hora_chegada_operacao", + "hora_saida_operacao", + "hora_retorno_operacao", + "hora_liberacao_motorista", +} + +def _to_date_py(v) -> Optional[date]: + if v is None: + return None + if isinstance(v, date) and not isinstance(v, datetime): + return v + try: + if not isinstance(v, str) and pd.isna(v): + return None + except Exception: + pass + if isinstance(v, datetime): + return v.date() + try: + dt = pd.to_datetime(v, dayfirst=True, errors="coerce") + if pd.isna(dt): + return None + return dt.date() + except Exception: + return None + +def _to_datetime_py(v) -> Optional[datetime]: + if v is None: + return None + if isinstance(v, datetime): + return v + try: + if not isinstance(v, str) and pd.isna(v): + return None + except Exception: + pass + if isinstance(v, date): + return datetime(v.year, v.month, v.day) + try: + dt = pd.to_datetime(v, errors="coerce") + if pd.isna(dt): + return None + return pd.Timestamp(dt).to_pydatetime() + except Exception: + return None + +def _to_time_str(v) -> str: + if v is None: + return "" + if isinstance(v, str): + return conv_excel_time(v) + return conv_excel_time(v) + +def _sanitize_payload_for_db(payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Converte valores conforme o tipo lógico da coluna: + - DATE_FIELDS -> date Python + - DATETIME_FIELDS -> datetime Python + - TIME_FIELDS -> 'HH:MM:SS' string + Também normaliza NaT/NaN -> None e strings vazias -> None (exceto TIME). + """ + if not payload: + return payload + sanitized: Dict[str, Any] = {} + for k, v in payload.items(): + try: + if not isinstance(v, str) and pd.isna(v): + v = None + except Exception: + pass + + if k in DATE_FIELDS: + val = _to_date_py(v) + elif k in DATETIME_FIELDS: + val = _to_datetime_py(v) + elif k in TIME_FIELDS: + s = _to_time_str(v) + val = s if s != "" else None + else: + if isinstance(v, str) and v.strip() == "": + val = None + else: + val = v + + sanitized[k] = val + return sanitized + + +# ========================================================== +# Persistência (salvar/salvar_lote) +# ========================================================== +_MODEL_COLS = set(RecebimentoRegistro.__table__.columns.keys()) + +def _filter_to_model(payload: Dict[str, Any]) -> Dict[str, Any]: + return {k: v for k, v in payload.items() if k in _MODEL_COLS} + + +def salvar(payload: Dict[str, Any], overwrite: bool = False) -> int: + db = _get_db() + try: + payload = _filter_to_model(payload) + payload = _sanitize_payload_for_db(payload) + + existente = None + if payload.get("id_planilha"): + existente = ( + db.query(RecebimentoRegistro) + .filter(RecebimentoRegistro.id_planilha == payload["id_planilha"]) + .first() + ) + + if existente and not overwrite: + raise ValueError(f"ID {payload['id_planilha']} já existe. Confirme a sobrescrita.") + + if existente and overwrite: + for k, v in payload.items(): + setattr(existente, k, v) + existente.updated_by = st.session_state.get("usuario") + db.commit() + db.refresh(existente) + return existente.id + + novo = RecebimentoRegistro(**payload) + novo.created_by = st.session_state.get("usuario") + db.add(novo) + db.commit() + db.refresh(novo) + return novo.id + except Exception: + db.rollback() + raise + finally: + db.close() + + +def salvar_lote( + payloads: List[Dict[str, Any]], + overwrite_ids: Optional[set] = None, + progress_cb: Optional[Callable[[int, int, int, int], None]] = None +) -> Tuple[int, int, List[str]]: + """ + Salva/atualiza registros em lote, usando uma única sessão e um único commit. + overwrite_ids: ids (id_planilha) que devem ser sobrescritos caso existam. + progress_cb: função chamada a cada item processado: progress_cb(processados, ok, fail, total) + Retorna: (ok, fail, erros) + """ + overwrite_ids = overwrite_ids or set() + ok = fail = 0 + erros: List[str] = [] + total = len(payloads) + + db = _get_db() + try: + for i, p in enumerate(payloads, start=1): + try: + p2 = _filter_to_model(p) + p2 = _sanitize_payload_for_db(p2) + + idp = p2.get("id_planilha") + + if idp and idp in overwrite_ids: + existente = db.query(RecebimentoRegistro).filter(RecebimentoRegistro.id_planilha == idp).first() + if existente: + for k, v in p2.items(): + setattr(existente, k, v) + existente.updated_by = st.session_state.get("usuario") + else: + novo = RecebimentoRegistro(**p2) + novo.created_by = st.session_state.get("usuario") + db.add(novo) + else: + novo = RecebimentoRegistro(**p2) + novo.created_by = st.session_state.get("usuario") + db.add(novo) + + ok += 1 + except Exception as e: + fail += 1 + erros.append(str(e)) + + if progress_cb: + try: + progress_cb(i, ok, fail, total) + except Exception: + pass + + db.commit() + return ok, fail, erros + + except Exception: + db.rollback() + raise + finally: + db.close() + + +# ========================================================== +# Validação do cabeçalho (ordem livre) +# ========================================================== +def _norm(s: str) -> str: + s = unicodedata.normalize("NFKD", str(s)).encode("ASCII", "ignore").decode("ASCII") + s = " ".join(s.split()) + return s.strip().lower() + +# Aliases/sinônimos aceitos -> nome canônico oficial +ALIASES_NORM = { + _norm("NF DIVERGENTE"): "DIVERGENCIA", + _norm("P.O"): "PO", + _norm("QTD SKU"): "Qtd. SKU", + _norm("APROVADO"): "APPROVED?", + _norm("HORA LIBERACAO PARA OPERACAO"): "HOR. LIBERAÇÃO PARA OPERAÇÃO", + _norm("HORA CHEGADA NA OPERACAO"): "HOR. DE CHEGADA NA OPERAÇÃO", + _norm("HORA SAIDA DA OPERACAO"): "HOR. DE SAÍDA DA OPERAÇÃO", + _norm("HORA RETORNO DA OPERACAO"): "HORA DE RETORNO DA OPERAÇÃO", + _norm("HORA LIBERACAO DO MOTORISTA"): "HORA DA LIBERAÇÃO DO MOTORISTA", +} + +def validar_e_reordenar(df: pd.DataFrame) -> Tuple[pd.DataFrame, List[str]]: + df = df[[c for c in df.columns if c not in IGNORED_COLS]].copy() + + official_norm_map = {_norm(col): col for col in OFFICIAL_COLUMNS} + optional_norm_map = {_norm(col): col for col in OPTIONAL_COLUMNS} + + found_map: Dict[str, str] = {} + usados_df = set() + + def resolve_official_name(norm_name: str) -> Optional[str]: + norm_name = ALIASES_NORM.get(norm_name, norm_name) + if norm_name in official_norm_map: + return official_norm_map[norm_name] + return None + + for col_df in df.columns: + norm = _norm(col_df) + oficial = resolve_official_name(norm) + if not oficial and norm in optional_norm_map: + oficial = optional_norm_map[norm] + if oficial and (col_df not in usados_df) and (oficial not in found_map): + found_map[oficial] = col_df + usados_df.add(col_df) + + faltantes = [col for col in OFFICIAL_COLUMNS if col not in found_map] + if faltantes: + return df, faltantes + + final_cols_df = [] + if "ID" in found_map: + final_cols_df.append(found_map["ID"]) + + ordered_df_cols = [found_map[col] for col in OFFICIAL_COLUMNS] + final_cols_df.extend(ordered_df_cols) + + extras = [c for c in df.columns if c not in set(final_cols_df)] + final_cols_df.extend(extras) + + df_ok = df[final_cols_df].copy() + + rename_map = {found_map[col]: col for col in OFFICIAL_COLUMNS if col in found_map} + if "ID" in found_map: + rename_map[found_map["ID"]] = "ID" + df_ok.rename(columns=rename_map, inplace=True) + + return df_ok, [] + + +# ========================================================== +# Excel: leitura (cache por conteúdo) +# ========================================================== +def gerar_modelo_oficial_xlsx() -> bytes: + exemplo = pd.DataFrame(columns=OFFICIAL_COLUMNS) + bio = io.BytesIO() + try: + with pd.ExcelWriter(bio, engine="openpyxl") as xw: + exemplo.to_excel(xw, index=False, sheet_name="Recebimento") + bio.seek(0) + return bio.read() + except Exception: + return b"" + +@st.cache_data(show_spinner=False) +def _read_file_cached(file_bytes: bytes, filename_lower: str) -> pd.DataFrame: + bio = io.BytesIO(file_bytes) + if filename_lower.endswith(".csv"): + return pd.read_csv(bio, sep=None, engine="python") + return pd.read_excel(bio, engine="openpyxl") + + +# ========================================================== +# Linha → payload e duplicidades (NaT-safe) +# ========================================================== +def linha_para_payload(row: pd.Series) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + + def _set(campo: str, valor: Any): + if (not isinstance(valor, str) and pd.isna(valor)) or (isinstance(valor, str) and valor.strip() == ""): + return + if isinstance(valor, time): + valor = valor.strftime("%H:%M:%S") + elif isinstance(valor, datetime): + valor = valor.strftime("%Y-%m-%d %H:%M:%S") + elif isinstance(valor, date) and not isinstance(valor, datetime): + valor = valor.isoformat() + if valor is None or (isinstance(valor, str) and valor.strip() == ""): + return + payload[campo] = valor + + for col_excel, (campo, conv) in COLUMN_MAP.items(): + raw = row.get(col_excel, None) + if conv: + try: + val = conv(raw) + except Exception: + val = None + else: + if raw in ("", "NaN") or (isinstance(raw, float) and pd.isna(raw)): + val = None + else: + val = raw + _set(campo, val) + + return payload + +_CONTENT_FIELDS = [m[0] for k, m in COLUMN_MAP.items() if m[0] != "id_planilha"] + +def _hash_content(payload: Dict[str, Any]) -> str: + key = tuple((k, payload.get(k)) for k in sorted(_CONTENT_FIELDS)) + return hashlib.sha256(repr(key).encode("utf-8")).hexdigest() + +def _duplicados_no_arquivo(payloads: List[Dict[str, Any]]) -> List[int]: + seen = {} + dups = [] + for idx, p in enumerate(payloads): + h = _hash_content(p) + if h in seen: + dups.append(idx) + else: + seen[h] = idx + return dups + +def _iguais_no_banco(payloads: List[Dict[str, Any]]) -> List[int]: + """(Compat; não usado na decisão final)""" + iguais = [] + db = _get_db() + try: + for idx, p in enumerate(payloads): + idp = p.get("id_planilha") + if not idp: + continue + reg = db.query(RecebimentoRegistro).filter(RecebimentoRegistro.id_planilha == idp).first() + if not reg: + continue + campos = [c for c in _CONTENT_FIELDS if hasattr(reg, c)] + equal = all(getattr(reg, campo) == p.get(campo) for campo in campos) + if equal: + iguais.append(idx) + return iguais + finally: + db.close() + +def _atribuir_ids_automaticos(payloads: List[Dict[str, Any]]): + atual_max = _get_max_id_planilha() + usados_arquivo = {p.get("id_planilha") for p in payloads if p.get("id_planilha")} + usados_arquivo.discard(None) + db = _get_db() + try: + usados_db = {r[0] for r in db.query(RecebimentoRegistro.id_planilha) + .filter(RecebimentoRegistro.id_planilha.isnot(None)).all()} + finally: + db.close() + proximo = max(atual_max, *(list(usados_db) or [0])) + 1 + for p in payloads: + if not p.get("id_planilha"): + while proximo in usados_arquivo or proximo in usados_db: + proximo += 1 + p["id_planilha"] = proximo + usados_arquivo.add(proximo) + proximo += 1 + +def _normalize_times_in_df(df: pd.DataFrame) -> pd.DataFrame: + df2 = df.copy() + for col in TIME_COLUMNS: + if col in df2.columns: + df2[col] = df2[col].apply(conv_excel_time) + return df2 + + +# ========================================================== +# Utilitários KPI (relatórios) +# ========================================================== +def _months_in_period(dates: pd.Series) -> int: + if dates.empty: + return 0 + dts = pd.to_datetime(dates, errors="coerce") + dts = dts.dropna() + if dts.empty: + return 0 + return dts.dt.to_period("M").nunique() + +def _daily_count(df_dates: pd.Series) -> int: + dts = pd.to_datetime(df_dates, errors="coerce").dropna() + return dts.nunique() + +def _kpis_metas(total_reg: int, datas: pd.Series, meta_diaria: float, meta_mensal: float): + qtd_dias = _daily_count(datas) + qtd_meses = _months_in_period(datas) + media_dia = (total_reg / qtd_dias) if qtd_dias else 0.0 + alvo_mes_total = (meta_mensal * qtd_meses) if meta_mensal else 0.0 + alvo_dia_total = (meta_diaria * qtd_dias) if meta_diaria else 0.0 + + c1, c2, c3, c4 = st.columns(4) + c1.metric("Total no período", total_reg) + c2.metric("Dias (distintos)", qtd_dias) + c3.metric("Média/dia", f"{media_dia:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")) + if meta_diaria: + perc_dia = (media_dia / meta_diaria * 100.0) if meta_diaria else 0 + c4.metric("Ating. meta diária", f"{perc_dia:.1f}%") + else: + c4.metric("Ating. meta diária", "—") + + c5, c6 = st.columns(2) + if meta_mensal: + ating_mes = (total_reg / alvo_mes_total * 100.0) if alvo_mes_total else 0 + c5.metric("Ating. meta mensal (aj. #meses)", f"{ating_mes:.1f}%") + else: + c5.metric("Ating. meta mensal", "—") + if meta_diaria: + ating_dia_tot = (total_reg / alvo_dia_total * 100.0) if alvo_dia_total else 0 + c6.metric("Ating. total vs soma metas diárias", f"{ating_dia_tot:.1f}%") + else: + c6.metric("Ating. total vs soma metas diárias", "—") + + +# ========================================================== +# Helpers de UI (preview + processamento) +# ========================================================== +def _safe_preview(df: pd.DataFrame, title: str = "Pré-visualização", rows: int = 20): + st.write(f"{title} (até {rows} linhas):") + st.dataframe(df.head(rows), use_container_width=True) + + +def _processar_arquivo( + file_bytes: bytes, + filename_lower: str, + auto_id_missing: bool +) -> Tuple[Optional[pd.DataFrame], Optional[List[Dict[str, Any]]], List[int], List[int], List[int]]: + """ + Pipeline: + 1) leitura + 2) validação/reordenação + 3) normalização de horas (preview) + 4) conversão linha->payload + 5) id automático (opcional) + 6) checagens: duplicados em arquivo, iguais no banco, ids existentes + """ + status = st.status("Iniciando processamento do arquivo...", expanded=True) + try: + status.update(label="📥 Lendo arquivo...", state="running") + df_raw = _read_file_cached(file_bytes, filename_lower) + _safe_preview(df_raw, title="Prévia do arquivo carregado") + + status.update(label="✅ Validando cabeçalho e reordenando...", state="running") + df_valid, faltantes = validar_e_reordenar(df_raw) + if faltantes: + status.update(label="❌ Validação falhou", state="error") + st.error("Importação bloqueada. Coluna(s) obrigatória(s) ausente(s):") + for f in faltantes: + st.markdown(f"- **{f}**") + st.info("Corrija a planilha para prosseguir. Dica: baixe o *Modelo oficial* acima.") + return None, None, [], [], [] + + status.update(label="⏱️ Normalizando colunas de horário para HH:MM:SS...", state="running") + df_preview = _normalize_times_in_df(df_valid) + + status.update(label="🔄 Convertendo linhas para payload...", state="running") + total = len(df_valid) + payloads: List[Dict[str, Any]] = [] + if total > 0: + pbar = st.progress(0, text="Convertendo...") + for i, (_, row) in enumerate(df_valid.iterrows()): + payloads.append(linha_para_payload(row)) + if ((i + 1) % max(1, total // 50) == 0) or (i + 1 == total): + pbar.progress(int(((i + 1) / total) * 100), text=f"Convertendo... {i+1}/{total}") + pbar.empty() + + if auto_id_missing and payloads: + status.update(label="🔢 Atribuindo IDs automáticos (linhas sem ID)...", state="running") + _atribuir_ids_automaticos(payloads) + + status.update(label="🧭 Verificando duplicidades/iguais/IDs existentes...", state="running") + idx_dups_arquivo = _duplicados_no_arquivo(payloads) + idx_iguais_db = _iguais_no_banco(payloads) + + ids_plan = [p.get("id_planilha") for p in payloads if p.get("id_planilha")] + db = _get_db() + try: + existentes = [] + if ids_plan: + existentes = [x[0] for x in db.query(RecebimentoRegistro.id_planilha) + .filter(RecebimentoRegistro.id_planilha.in_(ids_plan)).all()] + finally: + db.close() + + status.update(label="✅ Processamento concluído", state="complete") + return df_preview, payloads, idx_dups_arquivo, idx_iguais_db, existentes + + except Exception as e: + status.update(label="❌ Ocorreu um erro durante o processamento", state="error") + st.exception(e) + return None, None, [], [], [] + + +# ========================================================== +# Formulário manual +# ========================================================== +def formulario(payload: Optional[Dict[str, Any]] = None, key_prefix: str = "new") -> Dict[str, Any]: + sug = sugestao_defaults() + if payload is None: + payload = {} + + id_planilha_auto = payload.get("id_planilha") or _next_id_planilha() + + st.markdown("### 🧾 Cabeçalho") + c1, c2, c3, c4, c5 = st.columns(5) + c1.text_input("ID (planilha) — automático", value=str(id_planilha_auto), disabled=True, key=f"{key_prefix}__id_planilha_view") + data = c2.date_input("Data", value=payload.get("data") or date.today(), key=f"{key_prefix}__data") + data_emissao = c3.date_input("Data de Emissão", value=payload.get("data_emissao") or date.today(), key=f"{key_prefix}__data_emissao") + nf = c4.text_input("Nota Fiscal", value=payload.get("nota_fiscal") or "", key=f"{key_prefix}__nota_fiscal") + fornecedor = c5.text_input("Fornecedor", value=payload.get("fornecedor") or "", key=f"{key_prefix}__fornecedor") + + c6, c7, c8, c9, c10 = st.columns(5) + placa = c6.text_input("Placa do Veículo", value=payload.get("placa_veiculo") or "", key=f"{key_prefix}__placa_veiculo") + trans = c7.text_input("Transportadora", value=payload.get("transportadora") or sug.get("transportadora") or "", key=f"{key_prefix}__transportadora") + po = c8.text_input("PO", value=payload.get("po") or "", key=f"{key_prefix}__po") + incot = c9.text_input("Incoterms", value=payload.get("incoterms") or sug.get("incoterms") or "", key=f"{key_prefix}__incoterms") + qtd = c10.number_input("Qtd. SKU", min_value=0, value=int(payload.get("qtd_sku") or 0), key=f"{key_prefix}__qtd_sku") + + st.markdown("### ⏱️ Horários (HH:MM:SS)") + c11, c12, c13, c14, c15 = st.columns(5) + hc_port = c11.text_input("Chegada Portaria", value=conv_excel_time(payload.get("hora_chegada_portaria")), key=f"{key_prefix}__hora_chegada_portaria") + hc_ifs = c12.text_input("Chegada IFS", value=conv_excel_time(payload.get("hora_chegada_ifs")), key=f"{key_prefix}__hora_chegada_ifs") + hs_ifs = c13.text_input("Saída IFS/WMS", value=conv_excel_time(payload.get("hora_saida_ifs_wms")), key=f"{key_prefix}__hora_saida_ifs_wms") + hlib = c14.text_input("Liberação p/ Operação", value=conv_excel_time(payload.get("hora_liberacao_operacao")), key=f"{key_prefix}__hora_liberacao_operacao") + hch_op = c15.text_input("Chegada Operação", value=conv_excel_time(payload.get("hora_chegada_operacao")), key=f"{key_prefix}__hora_chegada_operacao") + + c16, c17, c18, c19, c20 = st.columns(5) + hs_op = c16.text_input("Saída Operação", value=conv_excel_time(payload.get("hora_saida_operacao")), key=f"{key_prefix}__hora_saida_operacao") + hret = c17.text_input("Retorno Operação", value=conv_excel_time(payload.get("hora_retorno_operacao")), key=f"{key_prefix}__hora_retorno_operacao") + hmot = c18.text_input("Liberação Motorista", value=conv_excel_time(payload.get("hora_liberacao_motorista")), key=f"{key_prefix}__hora_liberacao_motorista") + natureza = c19.text_input("Natureza da Operação", value=payload.get("natureza_operacao") or sug.get("natureza_operacao") or "", key=f"{key_prefix}__natureza_operacao") + tipo_op = c20.text_input("Tipo de Operação", value=payload.get("tipo_operacao") or sug.get("tipo_operacao") or "", key=f"{key_prefix}__tipo_operacao") + + c21, c22, c23, c24, c25 = st.columns(5) + barco = c21.text_input("Barco", value=payload.get("barco") or sug.get("barco") or "", key=f"{key_prefix}__barco") + div = c22.text_input("Divergência", value=payload.get("divergencia") or "", key=f"{key_prefix}__divergencia") + ifs = c23.text_input("IFS", value=payload.get("ifs") or sug.get("ifs") or "", key=f"{key_prefix}__ifs") + wms = c24.text_input("WMS", value=payload.get("wms") or sug.get("wms") or "", key=f"{key_prefix}__wms") + foto = c25.text_input("Fotografia (link/obs.)", value=payload.get("fotografia") or "", key=f"{key_prefix}__fotografia") + + c26, c27, c28, c29, c30 = st.columns(5) + entrega = c26.text_input("Entrega", value=payload.get("entrega") or sug.get("entrega") or "", key=f"{key_prefix}__entrega") + projeto = c27.text_input("Projeto", value=payload.get("projeto") or sug.get("projeto") or "", key=f"{key_prefix}__projeto") + good = c28.text_input("Good Receipt", value=payload.get("good_receipt") or "", key=f"{key_prefix}__good_receipt") + div_rec = c29.text_input("Divergência Recebimento", value=payload.get("divergencia_recebimento") or "", key=f"{key_prefix}__divergencia_recebimento") + qual = c30.text_input("Qualidade", value=payload.get("qualidade") or sug.get("qualidade") or "", key=f"{key_prefix}__qualidade") + + c31, c32, c33, c34, c35 = st.columns(5) + div_qual = c31.text_input("Divergência Qualidade", value=payload.get("divergencia_qualidade") or "", key=f"{key_prefix}__divergencia_qualidade") + obs = c32.text_area("Observação", value=payload.get("observacao") or "", height=80, key=f"{key_prefix}__observacao") + agend = c33.text_input("Agendamento", value=payload.get("agendamento") or sug.get("agendamento") or "", key=f"{key_prefix}__agendamento") + resp = c34.text_input("Responsável", value=payload.get("responsavel") or sug.get("responsavel") or "", key=f"{key_prefix}__responsavel") + po_alt = c35.text_input("P.O (alternativo)", value=payload.get("po_alt") or "", key=f"{key_prefix}__po_alt") + + c36, c37, _ = st.columns(3) + pn = c36.text_input("PN", value=payload.get("pn") or "", key=f"{key_prefix}__pn") + lot_batch = c37.text_input("LOT BATCH", value=payload.get("lot_batch") or "", key=f"{key_prefix}__lot_batch") + + st.markdown("### ✅ Sinalizações") + c36b, c37b, c38b, c39b = st.columns(4) + quimicos = c36b.selectbox("Químicos", ["SIM", "NÃO", "N/A"], + index=["SIM","NÃO","N/A"].index("SIM" if payload.get("quimicos") is True else "NÃO" if payload.get("quimicos") is False else "N/A"), + key=f"{key_prefix}__quimicos") + fds = c37b.selectbox("FDS", ["SIM", "NÃO", "N/A"], + index=["SIM","NÃO","N/A"].index("SIM" if payload.get("fds") is True else "NÃO" if payload.get("fds") is False else "N/A"), + key=f"{key_prefix}__fds") + repetro = c38b.selectbox("Repetro", ["SIM", "NÃO", "N/A"], + index=["SIM","NÃO","N/A"].index("SIM" if payload.get("repetro") is True else "NÃO" if payload.get("repetro") is False else "N/A"), + key=f"{key_prefix}__repetro") + aprovado = c39b.selectbox("Aprovado", ["SIM", "NÃO", "N/A"], + index=["SIM","NÃO","N/A"].index("SIM" if payload.get("aprovado") is True else "NÃO" if payload.get("aprovado") is False else "N/A"), + key=f"{key_prefix}__aprovado") + + return { + "id_planilha": (payload.get("id_planilha") or _next_id_planilha()), + "data": data, + "data_emissao": data_emissao, + "nota_fiscal": nf, + "fornecedor": fornecedor, + "placa_veiculo": placa, + "transportadora": trans, + "po": po, + "incoterms": incot, + "qtd_sku": qtd, + + "hora_chegada_portaria": hc_port, + "hora_chegada_ifs": hc_ifs, + "hora_saida_ifs_wms": hs_ifs, + "hora_liberacao_operacao": hlib, + "hora_chegada_operacao": hch_op, + "hora_saida_operacao": hs_op, + "hora_retorno_operacao": hret, + "hora_liberacao_motorista": hmot, + + "natureza_operacao": natureza, + "tipo_operacao": tipo_op, + "barco": barco, + "divergencia": div, + + "ifs": ifs, + "wms": wms, + "fotografia": foto, + "entrega": entrega, + "projeto": projeto, + "good_receipt": good, + "divergencia_recebimento": div_rec, + "qualidade": qual, + "divergencia_qualidade": div_qual, + "observacao": obs, + "agendamento": agend, + "responsavel": resp, + + "po_alt": po_alt, + "pn": pn, + "lot_batch": lot_batch, + + "quimicos": conv_bool(quimicos), + "fds": conv_bool(fds), + "repetro": conv_bool(repetro), + "aprovado": conv_bool(aprovado), + } + + +# ========================================================== +# 🚪 Portão Manual do Módulo +# ========================================================== +def recebimento_manual_gate() -> bool: + st.session_state.setdefault("__rec_allow__", False) + st.session_state.setdefault("__upl_file_bytes__", None) + st.session_state.setdefault("__upl_filename__", None) + st.session_state.setdefault("__df_preview__", None) + st.session_state.setdefault("__payloads_ready__", None) + st.session_state.setdefault("__existentes__", []) + st.session_state.setdefault("__idx_dups__", []) + st.session_state.setdefault("__idx_iguais_db__", []) + st.session_state.setdefault("__import_auto_id__", True) + + st.markdown("### 🔒 Modo manual — Recebimento") + st.caption("Este módulo **não executa automaticamente**. Use os botões abaixo para controlar a execução.") + with st.form("rec_gate_form", clear_on_submit=False): + c1, c2, c3 = st.columns([1, 1, 1]) + run_now = c1.form_submit_button("▶️ Executar módulo agora", type="primary", use_container_width=True) + freeze = c2.form_submit_button("🧊 Congelar (parar atualizações)", use_container_width=True) + reset = c3.form_submit_button("🔄 Recarregar & limpar prévias", use_container_width=True) + + if run_now: + st.session_state["__rec_allow__"] = True + + if freeze: + st.session_state["__rec_allow__"] = False + + if reset: + st.session_state["__upl_file_bytes__"] = None + st.session_state["__upl_filename__"] = None + st.session_state["__df_preview__"] = None + st.session_state["__payloads_ready__"] = None + st.session_state["__existentes__"] = [] + st.session_state["__idx_dups__"] = [] + st.session_state["__idx_iguais_db__"] = [] + st.session_state["__rec_allow__"] = False + st.info("Estados limpos. Módulo congelado. Clique em **▶️ Executar módulo agora** para rodar novamente.") + + return bool(st.session_state["__rec_allow__"]) + + +# ========================================================== +# Interface principal +# ========================================================== +def main(): + st.title("📦 Recebimento — Planilha Oficial (Modo Manual)") + + allowed = recebimento_manual_gate() + if not allowed: + st.stop() + + aba_form, aba_import, aba_reg, aba_rel, aba_admin = st.tabs([ + "Formulário Manual", "Importar Excel", "Registros", "Relatórios", "Admin" + ]) + + # ------------------ FORMULÁRIO ------------------ + with aba_form: + st.header("Novo Registro") + if _is_admin(): + admin_pin_area(key_prefix="__pin_top__") + + data_novo = formulario(key_prefix="new") + if st.button("💾 Salvar novo", type="primary", key="__btn_salvar_manual__"): + status_save = st.status("⏳ Ainda processando...", expanded=False, state="running") + try: + novo_id = salvar(data_novo, overwrite=False) + status_save.update(label=f"✅ Registro criado (ID interno: {novo_id}) — ID (planilha): {data_novo['id_planilha']}", state="complete") + st.success(f"Registro criado (ID interno: {novo_id}) — ID (planilha): {data_novo['id_planilha']}") + except Exception as e: + status_save.update(label="❌ Falha ao salvar", state="error") + st.error(str(e)) + + st.divider() + st.header("Editar Registro Existente (por ID da planilha)") + c1, c2 = st.columns([1, 3]) + buscar = c1.number_input("ID da Planilha", min_value=0, key="edit__id_busca") + if c2.button("🔎 Carregar", key="__btn_buscar__"): + db = _get_db() + try: + reg = db.query(RecebimentoRegistro).filter(RecebimentoRegistro.id_planilha == buscar).first() + if not reg: + st.error("ID não encontrado.") + else: + payload = {c.name: getattr(reg, c.name) for c in reg.__table__.columns} + st.session_state["__edicao__"] = payload + st.success("Registro carregado.") + finally: + db.close() + + if st.session_state.get("__edicao__"): + st.subheader("Edição") + if _is_admin(): + pronto = validar_pin(key_prefix="__pin_val_edit__") + payload_edit = formulario(st.session_state["__edicao__"], key_prefix="edit") + if pronto and st.button("💾 Salvar alterações (sobrescrever)", key="__btn_salvar_edicao__"): + status_save = st.status("⏳ Ainda processando...", expanded=False, state="running") + try: + salvar(payload_edit, overwrite=True) + status_save.update(label="✅ Registro atualizado!", state="complete") + st.success("Registro atualizado!") + st.session_state.pop("__edicao__", None) + except Exception as e: + status_save.update(label="❌ Falha ao salvar alterações", state="error") + st.error(str(e)) + elif not pronto: + st.info("Valide um PIN ativo para autorizar a sobrescrita.") + else: + _ = formulario(st.session_state["__edicao__"], key_prefix="edit_readonly") + st.warning("A edição com sobrescrita requer perfil de administrador.") + + # ------------------ IMPORTAÇÃO ------------------ + with aba_import: + st.header("Importação de Arquivo Oficial") + st.caption("Nada é processado automaticamente. Use os formulários e botões abaixo.") + + col_left, col_right = st.columns([2, 1]) + + # FORM 1 — Seleção/Opções + with col_left.form("form_import_select", clear_on_submit=False): + upload = st.file_uploader( + "Selecione .xlsx ou .csv (sem processamento automático)", + type=["xlsx", "csv"], + key="__upl__" + ) + auto_id_missing = st.checkbox( + "Gerar ID automático p/ linhas sem ID", + value=st.session_state.get("__import_auto_id__", True), + key="__ck_autoid_form__" + ) + submit_carregar = st.form_submit_button("📦 Carregar para pré-processo", use_container_width=True) + if submit_carregar: + if upload is None: + st.warning("Selecione um arquivo primeiro.") + else: + st.session_state["__upl_file_bytes__"] = upload.getvalue() + st.session_state["__upl_filename__"] = upload.name + st.session_state["__import_auto_id__"] = bool(auto_id_missing) + st.success(f"Arquivo **{upload.name}** carregado. Agora clique em **⚙️ Processar agora** quando desejar.") + + with col_right: + st.download_button( + "📥 Modelo oficial (.xlsx)", + data=gerar_modelo_oficial_xlsx(), + file_name="modelo_recebimento_oficial.xlsx", + help="Baixe o layout exato esperado pela base (37 colunas; 'ID' é opcional).", + key="__btn_modelo__" + ) + + st.divider() + + # FORM 2 — Processar / Limpar prévia + with st.form("form_import_process", clear_on_submit=False): + b1, b2 = st.columns([1, 1]) + submit_processar = b1.form_submit_button("⚙️ Processar agora") + submit_reprocessar = b2.form_submit_button("🔁 Reprocessar (limpar prévia)") + + if submit_reprocessar: + st.session_state["__df_preview__"] = None + st.session_state["__payloads_ready__"] = None + st.session_state["__existentes__"] = [] + st.session_state["__idx_dups__"] = [] + st.session_state["__idx_iguais_db__"] = [] + st.info("Prévia descartada. Clique em **⚙️ Processar agora** para gerar novamente (usando o arquivo carregado).") + + if submit_processar: + file_bytes = st.session_state.get("__upl_file_bytes__", None) + filename_lower = (st.session_state.get("__upl_filename__", "") or "").lower() + auto_id = st.session_state.get("__import_auto_id__", True) + + if not file_bytes: + st.warning("Nenhum arquivo carregado. Use o formulário acima para carregar um arquivo.") + else: + df_preview, payloads, idx_dups, idx_iguais_db, existentes = _processar_arquivo( + file_bytes=file_bytes, + filename_lower=filename_lower, + auto_id_missing=auto_id + ) + if (df_preview is not None) and (payloads is not None): + st.session_state["__df_preview__"] = df_preview + st.session_state["__payloads_ready__"] = payloads + st.session_state["__existentes__"] = existentes + st.session_state["__idx_dups__"] = idx_dups + st.session_state["__idx_iguais_db__"] = idx_iguais_db + + if st.session_state.get("__df_preview__") is not None: + _safe_preview( + st.session_state["__df_preview__"], + title="Pré-visualização validada (horários normalizados)" + ) + + st.divider() + st.subheader("Salvar no banco (manual)") + + payloads = st.session_state.get("__payloads_ready__") + idx_dups_arquivo = st.session_state.get("__idx_dups__", []) + existentes = st.session_state.get("__existentes__", []) + + if payloads is None: + st.info("⏳ Ainda sem dados prontos para salvar. Use **⚙️ Processar agora** para preparar os dados.") + else: + # --- Cálculo de novos x ignorados com motivos --- + dups_arquivo_set = set(idx_dups_arquivo) + existentes_set = set(existentes) + existing_keys = _fetch_existing_unique_keys() + + payloads_to_save: List[Dict[str, Any]] = [] + ignored_rows: List[Dict[str, Any]] = [] + + skip_by_file = skip_by_id = skip_by_key = 0 + for i, p in enumerate(payloads): + motivo = None + if i in dups_arquivo_set: + motivo = "Duplicado no arquivo" + idp = p.get("id_planilha") + if motivo is None and idp and idp in existentes_set: + motivo = "ID da planilha já existente no banco" + key = _make_unique_key_from_values(p) + if motivo is None and key and key in existing_keys: + motivo = "Conteúdo já importado (chave única)" + + if motivo: + ignored_rows.append({ + "idx": i, + "Motivo": motivo, + "ID": idp, + "Data": p.get("data"), + "NF": p.get("nota_fiscal"), + "Fornecedor": p.get("fornecedor"), + "PO": p.get("po"), + "PN": p.get("pn"), + "LOT BATCH": p.get("lot_batch"), + "Placa": p.get("placa_veiculo"), + "_key": key, + }) + if motivo == "Duplicado no arquivo": + skip_by_file += 1 + elif motivo == "ID da planilha já existente no banco": + skip_by_id += 1 + elif motivo.startswith("Conteúdo já importado"): + skip_by_key += 1 + else: + payloads_to_save.append(p) + + total_original = len(payloads) + total_novos = len(payloads_to_save) + total_skip = total_original - total_novos + + if skip_by_file: + st.warning(f"⚠️ {skip_by_file} linha(s) duplicada(s) no arquivo foram ignoradas.") + if skip_by_id: + st.warning(f"🔁 {skip_by_id} linha(s) ignorada(s) por ID (planilha) já existente no banco.") + if skip_by_key: + st.warning(f"🚫 {skip_by_key} linha(s) ignorada(s) por conteúdo já importado (chave: Data+NF+Fornecedor+PO+PN+LOT BATCH+Placa).") + + st.info(f"📊 Resumo: **{total_novos} novas** serão importadas | **{total_skip}** ignoradas de **{total_original}** total.") + + # ---------- 🔎 Analisar linhas ignoradas (opcional) ---------- + st.divider() + st.subheader("🔎 Analisar linhas ignoradas (opcional)") + + payloads_to_force: List[Dict[str, Any]] = [] + overwrite_ids_force: set = set() + + if not ignored_rows: + st.caption("Sem linhas ignoradas.") + else: + df_ign = pd.DataFrame(ignored_rows) + with st.expander("Ver linhas ignoradas e motivos", expanded=False): + st.dataframe(df_ign.drop(columns=["_key"]), use_container_width=True) + + # Construir opções legíveis para seleção + options = [] + label_map = {} + for r in ignored_rows: + label = f"Linha {r['idx']+1} | {r['Motivo']} | ID={r['ID'] or '—'} | Data={r['Data'] or '—'} | NF={r['NF'] or '—'} | Forn={r['Fornecedor'] or '—'} | PO={r['PO'] or '—'}" + options.append(label) + label_map[label] = r + + sel = st.multiselect("Selecione as linhas ignoradas que deseja incluir mesmo assim:", options, key="__sel_ignored__") + + # Ações para conflitos de ID + actions_id = {} + for label in sel: + r = label_map[label] + if r["ID"] and r["ID"] in existentes_set: + actions_id[r["idx"]] = st.selectbox( + f"Ação p/ Linha {r['idx']+1} (ID {r['ID']}):", + ["Gerar novo ID", "Sobrescrever (admin + PIN)"], + key=f"__ign_act_{r['idx']}" + ) + + # Permitir duplicidade por chave (se houver esse motivo selecionado) + has_key_dup = any(label_map[l]["Motivo"].startswith("Conteúdo já importado") for l in sel) + allow_dup_key = True + if has_key_dup: + allow_dup_key = st.checkbox( + "✅ Permitir inserir mesmo com conteúdo já importado (pode gerar duplicidade)", + value=False, key="__allow_dup_key__" + ) + + # Preparar payloads selecionados + tratar conflitos + for label in sel: + r = label_map[label] + idx = r["idx"] + p = dict(payloads[idx]) # cópia + + # Conteúdo já importado — exige confirmação + if r["Motivo"].startswith("Conteúdo já importado") and not allow_dup_key: + continue # não incluir sem a confirmação + + # ID existente — agir conforme seleção + if r["ID"] and r["ID"] in existentes_set: + act = actions_id.get(idx, "Gerar novo ID") + if act == "Gerar novo ID": + p["id_planilha"] = None # será atribuído automático + else: + overwrite_ids_force.add(r["ID"]) + + payloads_to_force.append(p) + + # ---------- Botões de salvar ---------- + st.divider() + modo_rapido = st.checkbox("⚡ Modo rápido (salvar em lote, único commit)", value=True, key="__ck_modo_rapido__") + + can_save_new = len(payloads_to_save) > 0 + can_save_all = (len(payloads_to_save) + len(payloads_to_force)) > 0 + + csave, csave_all, cclr = st.columns([1, 2, 1]) + submit_save = csave.button("💾 Salvar apenas **novas**", disabled=not can_save_new, type="primary", key="__btn_save_new__") + submit_save_all = csave_all.button("💾 Salvar **TUDO** (novas + selecionadas das ignoradas)", disabled=not can_save_all, type="primary", key="__btn_save_all__") + clear_preview = cclr.button("🧹 Limpar prévia", key="__btn_clear_prev__") + + def _do_save(payloads_ok: List[Dict[str, Any]], overwrite_ids: set): + status_save = st.status("⏳ Ainda processando...", expanded=False, state="running") + contador_ph = st.empty() + pbar_save = st.progress(0, text="Iniciando gravação...") + + def _progress_cb(processados: int, ok: int, fail: int, total: int): + pct = int((processados / total) * 100) if total else 0 + pbar_save.progress(pct, text=f"Gravando... {processados}/{total}") + contador_ph.markdown(f"**Progresso:** {processados}/{total} | **OK:** {ok} | **Falhas:** {fail}") + + try: + ok, fail, erros = (0, 0, []) + if modo_rapido: + ok, fail, erros = salvar_lote(payloads_ok, overwrite_ids=set(overwrite_ids), progress_cb=_progress_cb) + else: + total_local = len(payloads_ok) + for i, p in enumerate(payloads_ok, start=1): + try: + overwrite = (p.get("id_planilha") in overwrite_ids) if p.get("id_planilha") else False + salvar(p, overwrite=overwrite) + ok += 1 + except Exception as e: + fail += 1 + st.write(f"Erro na linha {i}: {e}") + _progress_cb(i, ok, fail, total_local) + + if erros: + with st.expander("Detalhes de erros", expanded=False): + for i, msg in enumerate(erros, 1): + st.write(f"{i:02d}. {msg}") + + status_save.update(label=f"✅ Importação concluída — {ok} OK, {fail} falhas.", state="complete") + st.success(f"Importação concluída — {ok} OK, {fail} falhas.") + except Exception as e: + status_save.update(label="❌ Falha durante a importação", state="error") + st.exception(e) + finally: + pbar_save.empty() + # Limpar estados + st.session_state["__df_preview__"] = None + st.session_state["__payloads_ready__"] = None + st.session_state["__existentes__"] = [] + st.session_state["__idx_dups__"] = [] + st.session_state["__idx_iguais_db__"] = [] + + if submit_save: + # Apenas novas + _do_save(payloads_to_save, overwrite_ids=set()) + + if submit_save_all: + # Preparar conjunto final: novas + selecionadas das ignoradas + payloads_all = list(payloads_to_save) + list(payloads_to_force) + + # Atribuir IDs automáticos para os que ficaram sem ID (apenas no conjunto final) + if any(p.get("id_planilha") in (None, "") for p in payloads_all): + _atribuir_ids_automaticos(payloads_all) + + # Se houver sobrescritas, exigir admin + PIN + overwrite_ids_all = set(overwrite_ids_force) + if overwrite_ids_all: + if not _is_admin(): + st.error("Sobrescrever registros requer perfil de **Administrador**.") + else: + pronto = validar_pin(key_prefix="__pin_val_save_all__") + if not pronto: + st.warning("Valide um PIN ativo para autorizar sobrescritas e tente novamente.") + else: + _do_save(payloads_all, overwrite_ids=overwrite_ids_all) + else: + _do_save(payloads_all, overwrite_ids=set()) + + if clear_preview: + st.session_state["__df_preview__"] = None + st.session_state["__payloads_ready__"] = None + st.session_state["__existentes__"] = [] + st.session_state["__idx_dups__"] = [] + st.session_state["__idx_iguais_db__"] = [] + st.info("Prévia descartada. Clique em **⚙️ Processar agora** para gerar novamente.") + + # ------------------ LISTA ------------------ + with aba_reg: + st.header("Registros (com filtros)") + + db = _get_db() + try: + regs = ( + db.query(RecebimentoRegistro) + .order_by(RecebimentoRegistro.created_at.desc()) + .limit(5000) + .all() + ) + finally: + db.close() + + if not regs: + st.info("Nenhum registro encontrado.") + st.stop() + + df_base = pd.DataFrame([{c.name: getattr(r, c.name) for c in r.__table__.columns} for r in regs]) + + if not df_base.empty and "data" in df_base.columns: + df_base["data"] = pd.to_datetime(df_base["data"], errors="coerce").dt.date + + def rotulo_to_campo(rotulo: str) -> Optional[str]: + info = COLUMN_MAP.get(rotulo) + return info[0] if info else None + + ordem_rotulos = [] + if "ID" in OPTIONAL_COLUMNS: + ordem_rotulos.append("ID") + ordem_rotulos.extend(OFFICIAL_COLUMNS) + + colunas_display = [] + for rot in ordem_rotulos: + campo = rotulo_to_campo(rot) + if campo and campo in df_base.columns: + colunas_display.append((rot, campo)) + + df_disp = pd.DataFrame() + for rot, campo in colunas_display: + df_disp[rot] = df_base[campo] + + usados = {campo for _rot, campo in colunas_display} + extras = [c for c in df_base.columns if c not in usados] + for extra in extras: + df_disp[extra] = df_base[extra] + + with st.expander("🔎 Filtros", expanded=True): + c1, c2, c3 = st.columns(3) + f_po = c1.text_input("P.O (campo: po)", placeholder="ex.: 4500...", key="reg__f_po") + f_pn = c2.text_input("PN (campo: pn)", placeholder="ex.: 1Z23...", key="reg__f_pn") + f_lot = c3.text_input("LOT BATCH (campo: lot_batch)", placeholder="ex.: L123...", key="reg__f_lot") + + c4, c5, c6 = st.columns(3) + f_nf = c4.text_input("Nota Fiscal (campo: nota_fiscal)", placeholder="ex.: 12345", key="reg__f_nf") + f_forn = c5.text_input("Fornecedor (campo: fornecedor)", placeholder="ex.: ACME", key="reg__f_forn") + f_placa = c6.text_input("Placa do Veículo (campo: placa_veiculo)", placeholder="ex.: ABC1D23", key="reg__f_placa") + + c7, c8, c9 = st.columns([1, 1, 1]) + f_data_ini = c7.date_input("Data inicial (campo: data)", value=None, key="reg__f_data_ini") + f_data_fim = c8.date_input("Data final (campo: data)", value=None, key="reg__f_data_fim") + limpar = c9.button("Limpar filtros", key="reg__btn_limpar_filtros") + + if limpar: + for k in ["reg__f_po", "reg__f_pn", "reg__f_lot", "reg__f_nf", "reg__f_forn", "reg__f_placa", "reg__f_data_ini", "reg__f_data_fim"]: + if k in st.session_state: + del st.session_state[k] + st.rerun() + + df_filtrado = df_base.copy() + + def _contains(df, col, term): + if not term or col not in df.columns: + return pd.Series([True] * len(df)) + return df[col].astype(str).str.contains(str(term), case=False, na=False) + + if f_po: df_filtrado = df_filtrado[_contains(df_filtrado, "po", f_po)] + if f_pn: df_filtrado = df_filtrado[_contains(df_filtrado, "pn", f_pn)] + if f_lot: df_filtrado = df_filtrado[_contains(df_filtrado, "lot_batch", f_lot)] + if f_nf: df_filtrado = df_filtrado[_contains(df_filtrado, "nota_fiscal", f_nf)] + if f_forn: df_filtrado = df_filtrado[_contains(df_filtrado, "fornecedor", f_forn)] + if f_placa:df_filtrado = df_filtrado[_contains(df_filtrado, "placa_veiculo", f_placa)] + if "data" in df_filtrado.columns: + if f_data_ini: + df_filtrado = df_filtrado[df_filtrado["data"] >= f_data_ini] + if f_data_fim: + df_filtrado = df_filtrado[df_filtrado["data"] <= f_data_fim] + + df_disp_filtrado = pd.DataFrame() + for rot, campo in colunas_display: + if campo in df_filtrado.columns: + df_disp_filtrado[rot] = df_filtrado[campo] + for extra in extras: + if extra in df_filtrado.columns: + df_disp_filtrado[extra] = df_filtrado[extra] + + total_filtrado = len(df_disp_filtrado) + if "DATA" in df_disp_filtrado.columns: + datas_validas = pd.to_datetime(df_disp_filtrado["DATA"], errors="coerce").dt.date.dropna() + if not datas_validas.empty: + st.caption( + f"Exibindo **{total_filtrado}** registro(s). " + f"Primeira data: **{datas_validas.min()}** — Última data: **{datas_validas.max()}**." + ) + else: + st.caption(f"Exibindo **{total_filtrado}** registro(s).") + else: + st.caption(f"Exibindo **{total_filtrado}** registro(s).") + + st.markdown("**Colunas visíveis**") + final_labels_order = list(df_disp_filtrado.columns) + + vis_key = "__cols_visiveis_labels__" + if vis_key not in st.session_state: + st.session_state[vis_key] = set(final_labels_order) + else: + st.session_state[vis_key] = {lbl for lbl in st.session_state[vis_key] if lbl in final_labels_order} + if not st.session_state[vis_key]: + st.session_state[vis_key] = set(final_labels_order) + + def render_columns_selector(title: str, labels: List[str], state_key: str): + container_supported = hasattr(st, "popover") + ctx_mgr = st.popover(title) if container_supported else st.expander(title, expanded=False) + with ctx_mgr: + st.write("Marque as colunas que deseja **exibir**:") + ac1, ac2 = st.columns(2) + if ac1.button("Selecionar tudo"): + st.session_state[state_key] = set(labels) + if ac2.button("Limpar"): + st.session_state[state_key] = set() + + left, right = st.columns(2) + half = (len(labels) + 1) // 2 + for i, lbl in enumerate(labels): + col = left if i < half else right + checked = lbl in st.session_state[state_key] + new_val = col.checkbox(lbl, value=checked, key=f"__chk_col_{state_key}_{lbl}") + if new_val and lbl not in st.session_state[state_key]: + st.session_state[state_key].add(lbl) + if (not new_val) and (lbl in st.session_state[state_key]): + st.session_state[state_key].discard(lbl) + + render_columns_selector("⚙️ Definir colunas", final_labels_order, vis_key) + + visible_labels_sorted = [lbl for lbl in final_labels_order if lbl in st.session_state[vis_key]] + if not visible_labels_sorted: + st.warning("Nenhuma coluna selecionada. Selecione pelo menos uma para visualizar a tabela.") + else: + st.dataframe(df_disp_filtrado[visible_labels_sorted], use_container_width=True) + + cexp1, cexp2 = st.columns([1, 1]) + csv_data = df_disp_filtrado.to_csv(index=False).encode("utf-8-sig") + cexp1.download_button("⬇️ Exportar CSV (filtrados)", data=csv_data, file_name="registros_filtrados.csv") + xlsx_all = _df_to_excel_bytes(df_disp_filtrado) + if xlsx_all: + cexp2.download_button("⬇️ Exportar Excel (filtrados)", data=xlsx_all, file_name="registros_filtrados.xlsx") + else: + cexp2.caption("Excel indisponível (openpyxl ausente).") + + # ------------------ RELATÓRIOS ------------------ + with aba_rel: + st.header("Relatórios") + + db = _get_db() + try: + regs = ( + db.query(RecebimentoRegistro) + .order_by(RecebimentoRegistro.created_at.desc()) + .limit(20000) + .all() + ) + finally: + db.close() + + if not regs: + st.info("Nenhum registro para relatório.") + st.stop() + + rows = [] + for r in regs: + rows.append({ + "Data": getattr(r, "data", None), + "Fornecedor": getattr(r, "fornecedor", None), + "PO": getattr(r, "po", None), + "PO (alt)": getattr(r, "po_alt", None), + "PN": getattr(r, "pn", None), + "LOT BATCH": getattr(r, "lot_batch", None), + "Placa": getattr(r, "placa_veiculo", None), + "Transportadora": getattr(r, "transportadora", None), + "Aprovado": "SIM" if getattr(r, "aprovado", None) is True else ("NÃO" if getattr(r, "aprovado", None) is False else "N/A"), + "Divergência": getattr(r, "divergencia", None), + "SKU": getattr(r, "qtd_sku", None), + "Tipo de Operação": getattr(r, "tipo_operacao", None), + }) + dr = pd.DataFrame(rows) + + dts = pd.to_datetime(dr.get("Data"), errors="coerce") + if dts.notna().any(): + min_d = dts.min().date() + max_d = dts.max().date() + else: + min_d = date.today().replace(day=1) + max_d = date.today() + + dr["Data"] = dts.dt.date + + periodo = st.date_input( + "Período (Data inicial e final)", + value=(min_d, max_d), + help="Selecione uma data inicial e final para filtrar todos os gráficos." + ) + if isinstance(periodo, tuple) and len(periodo) == 2: + d_ini, d_fim = periodo + else: + d_ini, d_fim = min_d, max_d + + drr = dr.copy() + if d_ini: + drr = drr[drr["Data"] >= d_ini] + if d_fim: + drr = drr[drr["Data"] <= d_fim] + + st.caption(f"Registros no período: **{len(drr)}** | Dias distintos: **{drr['Data'].dropna().nunique()}**") + + st.divider() + st.subheader("Evolução diária (contagem de registros)") + + evol = ( + drr.dropna(subset=["Data"]) + .groupby("Data") + .size() + .reset_index(name="Registros") + .sort_values("Data") + ) + cmeta1, cmeta2 = st.columns(2) + meta_diaria_evol = cmeta1.number_input("Meta diária (Evolução diária)", min_value=0.0, value=0.0, step=1.0) + meta_mensal_evol = cmeta2.number_input("Meta mensal (Evolução diária)", min_value=0.0, value=0.0, step=1.0) + + if ALT_AVAILABLE and not evol.empty: + base = alt.Chart(evol).encode(x=alt.X("Data:T", title="Data"), y=alt.Y("Registros:Q")) + linha = base.mark_line(color="#1f77b4", point=True) + layers = [linha] + if meta_diaria_evol and meta_diaria_evol > 0: + rule_df = pd.DataFrame({"y": [meta_diaria_evol]}) + rule = alt.Chart(rule_df).mark_rule(color="red", strokeDash=[6, 4]).encode(y="y:Q") + layers.append(rule) + chart = alt.layer(*layers).properties(height=320).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not evol.empty: + st.line_chart(evol.set_index("Data")["Registros"], height=320, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + _kpis_metas(total_reg=int(evol["Registros"].sum()), datas=evol["Data"], meta_diaria=meta_diaria_evol, meta_mensal=meta_mensal_evol) + + st.markdown("**Baixar dados — Evolução diária**") + _download_buttons(evol, "evolucao_diaria") + + st.divider() + st.subheader("Top 10 Fornecedores (por quantidade)") + top_forn = ( + drr["Fornecedor"].fillna("N/D") + .value_counts() + .head(10) + .reset_index(name="Registros") + .rename(columns={"index": "Fornecedor"}) + ) + + cmeta3, cmeta4 = st.columns(2) + meta_diaria_forn = cmeta3.number_input("Meta diária (Top Fornecedores)", min_value=0.0, value=0.0, step=1.0) + meta_mensal_forn = cmeta4.number_input("Meta mensal (Top Fornecedores)", min_value=0.0, value=0.0, step=1.0) + + if ALT_AVAILABLE and not top_forn.empty: + base = alt.Chart(top_forn).encode( + x=alt.X("Registros:Q"), + y=alt.Y("Fornecedor:N", sort="-x", title="Fornecedor") + ) + barras = base.mark_bar(color="#1f77b4") + chart = barras.properties(height=320).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not top_forn.empty: + top_series = top_forn.set_index("Fornecedor")["Registros"] + st.bar_chart(top_series, height=320, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + total_forn = pd.to_numeric(top_forn["Registros"], errors="coerce").fillna(0).sum() + _kpis_metas(total_reg=int(total_forn), datas=drr["Data"], meta_diaria=meta_diaria_forn, meta_mensal=meta_mensal_forn) + + st.markdown("**Baixar dados — Top 10 Fornecedores**") + _download_buttons(top_forn, "top10_fornecedores") + + st.divider() + st.subheader("Registros por Transportadora") + por_transp = ( + drr["Transportadora"].fillna("N/D") + .value_counts() + .reset_index(name="Registros") + .rename(columns={"index": "Transportadora"}) + ) + + cmeta5, cmeta6 = st.columns(2) + meta_diaria_transp = cmeta5.number_input("Meta diária (Transportadora)", min_value=0.0, value=0.0, step=1.0) + meta_mensal_transp = cmeta6.number_input("Meta mensal (Transportadora)", min_value=0.0, value=0.0, step=1.0) + + if ALT_AVAILABLE and not por_transp.empty: + base = alt.Chart(por_transp).encode( + x=alt.X("Registros:Q"), + y=alt.Y("Transportadora:N", sort="-x", title="Transportadora") + ) + barras = base.mark_bar(color="#ff7f0e") + chart = barras.properties(height=320).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not por_transp.empty: + st.bar_chart(por_transp.set_index("Transportadora")["Registros"], height=320, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + total_transp = pd.to_numeric(por_transp["Registros"], errors="coerce").fillna(0).sum() + _kpis_metas(total_reg=int(total_transp), datas=drr["Data"], meta_diaria=meta_diaria_transp, meta_mensal=meta_mensal_transp) + + st.markdown("**Baixar dados — Registros por Transportadora**") + _download_buttons(por_transp, "registros_por_transportadora") + + st.divider() + st.subheader("Status de Aprovação") + aprov = ( + drr["Aprovado"].fillna("N/A") + .value_counts() + .reset_index(name="Registros") + .rename(columns={"index": "Status"}) + ) + + cmeta7, cmeta8 = st.columns(2) + meta_diaria_aprov = cmeta7.number_input("Meta diária (Aprovação)", min_value=0.0, value=0.0, step=1.0) + meta_mensal_aprov = cmeta8.number_input("Meta mensal (Aprovação)", min_value=0.0, value=0.0, step=1.0) + + if ALT_AVAILABLE and not aprov.empty: + base = alt.Chart(aprov).encode( + x=alt.X("Registros:Q"), + y=alt.Y("Status:N", sort="-x", title="Status de aprovação") + ) + barras = base.mark_bar(color="#2ca02c") + chart = barras.properties(height=300).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not aprov.empty: + st.bar_chart(aprov.set_index("Status")["Registros"], height=300, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + total_aprov = pd.to_numeric(aprov["Registros"], errors="coerce").fillna(0).sum() + _kpis_metas(total_reg=int(total_aprov), datas=drr["Data"], meta_diaria=meta_diaria_aprov, meta_mensal=meta_mensal_aprov) + + st.markdown("**Baixar dados — Status de Aprovação**") + _download_buttons(aprov, "status_aprovacao") + + st.divider() + st.subheader("Distribuição por Tipo de Operação") + tipo_op_df = ( + drr["Tipo de Operação"].fillna("N/D") + .value_counts() + .reset_index(name="Registros") + .rename(columns={"index": "Tipo de Operação"}) + ) + + cmeta9, cmeta10 = st.columns(2) + meta_diaria_tipo = cmeta9.number_input("Meta diária (Tipo de Operação)", min_value=0.0, value=0.0, step=1.0, key="rel__meta_diaria_tipo") + meta_mensal_tipo = cmeta10.number_input("Meta mensal (Tipo de Operação)", min_value=0.0, value=0.0, step=1.0, key="rel__meta_mensal_tipo") + + if ALT_AVAILABLE and not tipo_op_df.empty: + base = alt.Chart(tipo_op_df).encode( + x=alt.X("Registros:Q"), + y=alt.Y("Tipo de Operação:N", sort="-x", title="Tipo de Operação") + ) + barras = base.mark_bar(color="#9467bd") + chart = barras.properties(height=320).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not tipo_op_df.empty: + st.bar_chart(tipo_op_df.set_index("Tipo de Operação")["Registros"], height=320, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + total_tipo = pd.to_numeric(tipo_op_df["Registros"], errors="coerce").fillna(0).sum() + _kpis_metas(total_reg=int(total_tipo), datas=drr["Data"], meta_diaria=meta_diaria_tipo, meta_mensal=meta_mensal_tipo) + + st.markdown("**Baixar dados — Tipo de Operação**") + _download_buttons(tipo_op_df, "registros_por_tipo_de_operacao") + + # ------------------ ADMIN ------------------ + with aba_admin: + st.header("Área Administrativa") + st.caption("Apenas administradores podem resetar o banco. Requer login + PIN válido.") + + is_admin = admin_login_area() + + if is_admin: + st.divider() + st.subheader("PIN de Segurança") + st.caption("Você precisa de um PIN ativo para autorizar ações destrutivas.") + admin_pin_area(key_prefix="__pin_admin_tab__") + pronto = validar_pin(key_prefix="__pin_val_admin_tab__") + else: + pronto = False + + st.divider() + st.subheader("🧨 Reset da Tabela de Recebimentos") + st.warning( + "Esta ação **apagará TODOS os registros** de **RecebimentoRegistro** de forma **irreversível**.\n\n" + "Recomendo **baixar um backup** antes de continuar." + ) + + df_backup = _fetch_all_recebimentos_df() + total_reg = len(df_backup) + st.info(f"Registros encontrados: **{total_reg}**") + if total_reg > 0: + col_b1, col_b2 = st.columns(2) + csv_bytes = df_backup.to_csv(index=False).encode("utf-8-sig") + col_b1.download_button("⬇️ Baixar CSV (backup)", data=csv_bytes, file_name="backup_recebimento.csv", key="__dl_bkp_csv__") + + xlsx_bytes = _df_to_excel_bytes(df_backup, sheet_name="Backup") + if xlsx_bytes: + col_b2.download_button("⬇️ Baixar Excel (backup)", data=xlsx_bytes, file_name="backup_recebimento.xlsx", key="__dl_bkp_xlsx__") + else: + col_b2.caption("Excel indisponível (openpyxl ausente).") + + st.divider() + st.subheader("Confirmar e Executar Reset") + if not is_admin: + st.error("Você precisa efetuar login de administrador.") + elif not pronto: + st.error("PIN inválido ou ausente. Valide um PIN ativo para prosseguir.") + else: + st.caption("Para confirmar, digite **RESETAR** abaixo:") + confirm_str = st.text_input("Confirmação", placeholder="Digite RESETAR", key="__confirm_reset__") + really = (confirm_str.strip().upper() == "RESETAR") + + do_reset = st.button("🧨 Apagar TODOS os registros agora", type="primary", disabled=not really, key="__btn_reset_all__") + + if do_reset and really: + status = st.status("Executando reset...", expanded=False, state="running") + try: + apagados, err = reset_recebimento_registros() + if err: + status.update(label="❌ Falha no reset", state="error") + st.error(f"Erro ao resetar: {err}") + else: + try: + st.cache_data.clear() + except Exception: + pass + for k in ["__df_preview__", "__payloads_ready__", "__existentes__", "__idx_dups__", "__idx_iguais_db__"]: + st.session_state.pop(k, None) + + status.update(label=f"✅ Reset concluído. Registros apagados: {apagados}", state="complete") + st.success(f"Reset concluído. Registros apagados: {apagados}") + except Exception as e: + status.update(label="❌ Falha no reset", state="error") + st.exception(e) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/relatorio.py b/relatorio.py new file mode 100644 index 0000000000000000000000000000000000000000..f83860734886b0e39cf130ba69c5967359527d84 --- /dev/null +++ b/relatorio.py @@ -0,0 +1,180 @@ + +import streamlit as st +import pandas as pd +import altair as alt +from io import BytesIO +from banco import SessionLocal +from models import Equipamento + + +def main(): + st.title("📊 Relatórios Operacionais") + + db = SessionLocal() + + try: + registros = db.query(Equipamento).all() + + if not registros: + st.info("Nenhum dado disponível para relatórios.") + return + + # ============================================================ + # 🧱 DATAFRAME PRINCIPAL + # ============================================================ + df = pd.DataFrame([ + { + "FPSO": r.fpso, + "Modal": r.modal, + "OSM": r.osm, + "Linhas OSM": r.linhas_osm, + "MROB": r.mrob, + "Linhas MROB": r.linhas_mrob, + "Quant Equip": r.quant_equip, + "Conferente": r.conferente, + "Especialista": r.especialista, + "PO": r.po, + "Partnumber": r.part_number, + "Material": r.material, + "Motivo": r.motivo, + "Dia Inclusao": r.dia_inclusao, + "Data Coleta": r.data_coleta + } + for r in registros + ]) + + df["Data Coleta"] = pd.to_datetime(df["Data Coleta"], errors="coerce").dt.date + + # ============================================================ + # 🔍 FILTROS + # ============================================================ + st.subheader("🔍 Filtros") + + col1, col2, col3 = st.columns(3) + + with col1: + filtro_fpso = st.multiselect("FPSO", sorted(df["FPSO"].dropna().unique())) + filtro_modal = st.multiselect("Modal", sorted(df["Modal"].dropna().unique())) + filtro_osm = st.multiselect("OSM", sorted(df["OSM"].dropna().unique())) + + with col2: + filtro_conferente = st.multiselect("Conferente", sorted(df["Conferente"].dropna().unique())) + filtro_especialista = st.multiselect("Especialista", sorted(df["Especialista"].dropna().unique())) + filtro_dia = st.multiselect("Dia de Inclusão (D1/D2/D3)", sorted(df["Dia Inclusao"].dropna().unique())) + + with col3: + filtro_motivo = st.multiselect("Motivo", sorted(df["Motivo"].dropna().unique())) + filtro_material = st.multiselect("Material", sorted(df["Material"].dropna().unique())) + filtro_po = st.multiselect("PO", sorted(df["PO"].dropna().unique())) + + data_min = df["Data Coleta"].min() + data_max = df["Data Coleta"].max() + dataInicio, dataFim = st.date_input("Período Coleta", [data_min, data_max]) + + # ============================================================ + # 🧲 APLICAÇÃO DOS FILTROS + # ============================================================ + filtros = [ + ("FPSO", filtro_fpso), + ("Modal", filtro_modal), + ("OSM", filtro_osm), + ("Conferente", filtro_conferente), + ("Especialista", filtro_especialista), + ("Dia Inclusao", filtro_dia), + ("Motivo", filtro_motivo), + ("Material", filtro_material), + ("PO", filtro_po) + ] + + for coluna, lista in filtros: + if lista: + df = df[df[coluna].isin(lista)] + + if dataInicio and dataFim: + df = df[(df["Data Coleta"] >= dataInicio) & (df["Data Coleta"] <= dataFim)] + + # ============================================================ + # 📌 KPI GERAIS + # ============================================================ + st.subheader("📌 Indicadores Gerais") + + colA, colB, colC, colD = st.columns(4) + + colA.metric("Registros", len(df)) + colB.metric("Containers Enviados", int(df["Quant Equip"].fillna(0).sum())) + colC.metric("Linhas OSM", int(df["Linhas OSM"].fillna(0).sum())) + colD.metric("Linhas MROB (Erros)", int(df["Linhas MROB"].fillna(0).sum())) + + # ============================================================ + # 🎯 ACURACIDADE POR FPSO + # ============================================================ + st.subheader("🎯 Acuracidade de Inclusão por FPSO (Meta 70%)") + + df_acur = df.groupby("FPSO")[["Linhas OSM", "Linhas MROB"]].sum().reset_index() + df_acur["Acuracidade (%)"] = ((df_acur["Linhas OSM"] - df_acur["Linhas MROB"]) / + df_acur["Linhas OSM"]) * 100 + + chart_acur = ( + alt.Chart(df_acur) + .mark_bar() + .encode( + x="FPSO:N", + y="Acuracidade (%):Q", + tooltip=["FPSO", alt.Tooltip("Acuracidade (%):Q", format=".2f")] + ) + ) + + st.altair_chart(chart_acur, use_container_width=True) + + # ============================================================ + # 📊 AGRUPAMENTO: OSM → FPSO → ERROS + # ============================================================ + st.subheader("📦 Erros por OSM e FPSO") + + df_osm = df.groupby(["FPSO", "OSM"]).agg({ + "Linhas OSM": "sum", + "Linhas MROB": "sum", + "Quant Equip": "sum" + }).reset_index() + + st.dataframe(df_osm, use_container_width=True) + + chart_osm = ( + alt.Chart(df_osm) + .mark_bar() + .encode( + x="OSM:N", + y="Linhas MROB:Q", + color="FPSO:N", + tooltip=["OSM", "FPSO", "Linhas OSM", "Linhas MROB"] + ) + ) + st.altair_chart(chart_osm, use_container_width=True) + + # ============================================================ + # 📋 DETALHAMENTO + # ============================================================ + st.subheader("📋 Detalhamento Completo (Dados Filtrados)") + st.dataframe(df, use_container_width=True) + + # ============================================================ + # 📥 EXPORTAÇÃO EXCEL + # ============================================================ + buffer = BytesIO() + with pd.ExcelWriter(buffer, engine="openpyxl") as writer: + df.to_excel(writer, index=False, sheet_name="Relatorio_Filtrado") + + buffer.seek(0) + + st.download_button( + label="⬇️ Exportar Relatório para Excel", + data=buffer, + file_name="relatorio_filtrado.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + + finally: + db.close() + + + diff --git a/repo_rnc.py b/repo_rnc.py new file mode 100644 index 0000000000000000000000000000000000000000..6cde1ad672ba6ec9a9285cc0c371ff2e92d9e7a0 --- /dev/null +++ b/repo_rnc.py @@ -0,0 +1,711 @@ + +# -*- coding: utf-8 -*- +""" +Módulo: Repositório RNC +- Mesmo comportamento do repositório Load, porém usando o ambiente fixo 'rnc' +- Usa a mesma tabela 'repo_arquivo' (opção A), filtrando por ambiente='rnc' +""" + +import os +import base64 +import hashlib +import mimetypes +from datetime import datetime + +import streamlit as st +import pandas as pd + +from banco import engine, Base, SessionLocal +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, Text + +from string import Template # Evita conflitos com { } em HTML/JS + +# ==================================================== +# Configuração específica deste módulo — Ambiente RNC +# ==================================================== +ENV_LABEL = "rnc" # <- chave para particionar por ambiente nesta página +REPO_ROOT = os.path.join("data_repo", ENV_LABEL) +os.makedirs(REPO_ROOT, exist_ok=True) + +# ============================== +# Modelo de dados — repositório +# (mesma tabela repo_arquivo) +# ============================== +class RepoArquivoRNC(Base): + """ + Mapeia a MESMA tabela 'repo_arquivo' usada pelo Load (opção A). + Usa 'extend_existing' para evitar conflitos quando a tabela já existe + no mesmo metadata em outro módulo. + """ + __tablename__ = "repo_arquivo" + __table_args__ = {"extend_existing": True} + + id = Column(Integer, primary_key=True, autoincrement=True) + ambiente = Column(String(16), nullable=False) # prod/test/etc; aqui usamos 'rnc' + storage_path = Column(String(512), nullable=False) # caminho no disco + original_name = Column(String(256), nullable=False) + title = Column(String(256), nullable=True) + description = Column(Text, nullable=True) + tags = Column(String(256), nullable=True) # separado por vírgulas + mime = Column(String(128), nullable=False) + size_kb = Column(Float, nullable=True) + uploaded_by = Column(String(128), nullable=False) + uploaded_at = Column(DateTime, nullable=False, default=datetime.now) + is_public = Column(Boolean, nullable=False, default=True) + +# Cria a tabela se não existir +Base.metadata.create_all(bind=engine) + +# ============================== +# Helpers — paths e segurança +# ============================== +def _repo_root() -> str: + """Pasta raiz do repositório deste módulo (ambiente fixo: rnc).""" + os.makedirs(REPO_ROOT, exist_ok=True) + return REPO_ROOT + +def _hash_bytes(b: bytes) -> str: + return hashlib.sha256(b).hexdigest() + +# MIME por extensão (fallback) +_EXT_MIME = { + ".pdf": "application/pdf", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xls": "application/vnd.ms-excel", +} +def _detect_mime_by_name(original_name: str) -> str: + guessed = mimetypes.guess_type(original_name)[0] + if guessed: + return guessed + ext = os.path.splitext(original_name)[1].lower() + return _EXT_MIME.get(ext, "application/octet-stream") + +def _save_file_to_repo(file_obj, original_name: str) -> tuple[str, float, str]: + """Salva o arquivo e retorna (storage_path, size_kb, mime).""" + content = file_obj.read() + size_kb = round(len(content) / 1024.0, 2) + mime = _detect_mime_by_name(original_name) + root = _repo_root() + ext = os.path.splitext(original_name)[1].lower() + uid = _hash_bytes(content)[:16] + storage_name = f"{uid}{ext}" + storage_path = os.path.join(root, storage_name) + with open(storage_path, "wb") as f: + f.write(content) + return storage_path, size_kb, mime + +# ============================== +# Preview — PDF e Excel +# ============================== +def _pdf_b64_cached(storage_path: str) -> str: + """Cache leve do PDF em base64 na sessão (chaveada por path).""" + key = f"__rnc_repo_pdf_b64__::{storage_path}" + if key in st.session_state: + return st.session_state[key] + with open(storage_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + st.session_state[key] = b64 + return b64 + +def _is_too_big_for_inline(storage_path: str, max_mb: int = 15) -> bool: + """Evita tentar embed de arquivos muito grandes (limite ajustável).""" + try: + return os.path.getsize(storage_path) > max_mb * 1024 * 1024 + except Exception: + return False + +def _button_open_new_tab_blob(storage_path: str, key_prefix: str, label: str = "🧭 Abrir em nova aba"): + """ + Renderiza um botão/link que abre o PDF em nova aba usando blob: (sem embed). + Mais robusto em ambientes com bloqueio de iframe/object. + """ + try: + b64 = _pdf_b64_cached(storage_path) + html_tpl = Template(r""" +
+ + +
+ + """) + html = html_tpl.substitute(K=key_prefix, B64=b64, LABEL=label) + st.components.v1.html(html, height=40) + except Exception as e: + st.warning(f"Não foi possível preparar abertura em nova aba: {e}") + +def _embed_pdf_blob(storage_path: str, height: int = 650, mode: str = "iframe"): + """ + Tenta exibir inline via blob: (iframe ou object). Se não carregar em 3,5s, + mostra status amigável para o usuário. + """ + try: + b64 = _pdf_b64_cached(storage_path) + viewer_iframe = '' + viewer_object = '' + viewer = viewer_object if mode == "object" else viewer_iframe + if mode == "object": + viewer = Template(viewer).substitute(H=int(height)) + + inject = ( + 'var o=document.getElementById("pdfobj"); var e=document.getElementById("pdfemb"); ' + 'if(o){o.setAttribute("data", url); viewerEl=o;} else if(e){e.setAttribute("src", url); viewerEl=e;}' + if mode == "object" + else 'var f=document.getElementById("pdfifr"); if(f){f.setAttribute("src", url); viewerEl=f;}' + ) + + html_tpl = Template(r""" + + + + + + + +
+ carregando visualizador ${MODE}… +
+
+ ${VIEWER} +
+
+ + + + """) + html = html_tpl.substitute( + H=int(height), + VIEWER=viewer, + B64=b64, + MODE=mode, + INJECT=inject + ) + st.components.v1.html(html, height=height + 46, scrolling=False) + except Exception as e: + st.warning(f"Não foi possível exibir o PDF (blob/{mode}): {e}") + +def _embed_pdf_pdfjs(storage_path: str, height: int = 650, theme: str = "dark"): + """PDF.js via CDN (alternativo) — com navegação, zoom e abrir em nova aba (Blob).""" + try: + b64 = _pdf_b64_cached(storage_path) + bg = "#0b1220" if theme == "dark" else "#f8fafc" + fg = "#e2e8f0" if theme == "dark" else "#0f172a" + btn = "#1e293b" if theme == "dark" else "#e2e8f0" + + html_tpl = Template(r""" + + + + + + + +
+ + + Página 1 / ? + + + + + +
+
+ +
+
+ + + + + + """) + html = html_tpl.substitute( + B64=b64, + BG=bg, + FG=fg, + BTN=btn, + VH=max(100, int(height - 54)) + ) + st.components.v1.html(html, height=height + 16, scrolling=False) + except Exception as e: + st.warning(f"Não foi possível exibir o PDF (PDF.js): {e}") + +def _preview_excel(storage_path: str): + """Exibe primeiras linhas de um Excel.""" + try: + path = storage_path.lower() + if path.endswith(".xlsx"): + df = pd.read_excel(storage_path, engine="openpyxl") + elif path.endswith(".xls"): + df = pd.read_excel(storage_path, engine="xlrd") + else: + st.info("Arquivo não reconhecido como Excel.") + return + st.dataframe(df.head(200), use_container_width=True) + except Exception as e: + st.warning(f"Não foi possível ler o Excel: {e}") + +def _render_pdf_preview(storage_path: str, height: int, key_prefix: str, default: str = "PDF.js (alternativo)"): + """ + Viewer por arquivo: + - Botão "Abrir em nova aba" (SEM embed) sempre visível. + - Inline (experimental): PDF.js, Iframe (Blob) ou OBJECT/EMBED (Blob). + - Arquivo grande: bypass para nova aba. + """ + # 1) Sempre oferecer "Abrir em nova aba" (mais confiável) + _button_open_new_tab_blob(storage_path, key_prefix=f"{key_prefix}_open", label="🧭 Abrir em nova aba") + + # 2) Bypass para PDFs muito grandes + if _is_too_big_for_inline(storage_path, max_mb=15): + st.info("📄 PDF grande — recomendado abrir em nova aba (acima).") + return + + # 3) Pré-visualização inline (experimental) + with st.expander("🔬 Pré‑visualização inline (experimental)", expanded=False): + viewer_opt = st.radio( + "Visualizador de PDF:", + options=["PDF.js (alternativo)", "Iframe (Blob)", "OBJECT/EMBED (Blob compat.)"], + index={"PDF.js (alternativo)": 0, "Iframe (Blob)": 1, "OBJECT/EMBED (Blob compat.)": 2}.get(default, 0), + key=f"{key_prefix}_viewer" + ) + if viewer_opt == "PDF.js (alternativo)": + _embed_pdf_pdfjs(storage_path, height=height, theme="dark") + elif viewer_opt == "Iframe (Blob)": + _embed_pdf_blob(storage_path, height=height, mode="iframe") + else: + _embed_pdf_blob(storage_path, height=height, mode="object") + +# ============================== +# UI — módulo +# ============================== +def main(): + st.title("📦 Repositório RNC") + st.caption("Admin importa arquivos (Excel/PDF) referentes às RNCs. Usuários consultam online e baixam.") + perfil = st.session_state.get("perfil", "usuario") + usuario = st.session_state.get("usuario") or "anon" + db = SessionLocal() + + # --------------------------- + # ADMIN — Upload e gestão + # --------------------------- + if perfil == "admin": + st.subheader("🛠️ Administração do Repositório") + with st.form("form_upload_repo_rnc"): + files = st.file_uploader( + "Selecione arquivos (Excel: .xlsx/.xls, PDF: .pdf)", + type=["xlsx", "xls", "pdf"], + accept_multiple_files=True + ) + default_is_public = st.checkbox("Disponibilizar como público", value=True) + tags = st.text_input("Tags (opcional, separadas por vírgula) ex.: 'rnc, cliente, ação'") + enviar = st.form_submit_button("📤 Enviar para repositório") + + if enviar and files: + ok_count = 0 + for f in files: + try: + storage_path, size_kb, mime = _save_file_to_repo(f, f.name) + registro = RepoArquivoRNC( + ambiente=ENV_LABEL, # <-- chave do ambiente + storage_path=storage_path, + original_name=f.name, + title=os.path.splitext(f.name)[0], + description=None, + tags=(tags or "").strip() or None, + mime=mime, + size_kb=size_kb, + uploaded_by=usuario, + uploaded_at=datetime.now(), + is_public=default_is_public + ) + db.add(registro) + db.commit() + ok_count += 1 + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar '{f.name}': {e}") + if ok_count > 0: + st.success(f"✅ {ok_count} arquivo(s) enviado(s) para o repositório.") + st.rerun() + + with st.expander("📋 Arquivos do repositório (Admin)", expanded=True): + col_s1, col_s2, col_s3 = st.columns([1.8, 1, 1]) + termo = col_s1.text_input("Buscar por título/nome/tags:") + somente_publicos = col_s2.selectbox("Visibilidade", ["todos", "públicos", "privados"], index=0) + tipo_arquivo = col_s3.selectbox("Tipo", ["todos", "pdf", "excel"], index=0) + + q = db.query(RepoArquivoRNC).filter(RepoArquivoRNC.ambiente == ENV_LABEL) + if termo.strip(): + like = f"%{termo.strip()}%" + q = q.filter( + (RepoArquivoRNC.title.ilike(like)) | + (RepoArquivoRNC.original_name.ilike(like)) | + (RepoArquivoRNC.tags.ilike(like)) + ) + if somente_publicos != "todos": + q = q.filter(RepoArquivoRNC.is_public == (somente_publicos == "públicos")) + if tipo_arquivo == "pdf": + q = q.filter((RepoArquivoRNC.mime.ilike("%pdf%")) | (RepoArquivoRNC.original_name.ilike("%.pdf"))) + elif tipo_arquivo == "excel": + q = q.filter( + (RepoArquivoRNC.original_name.ilike("%.xlsx")) | + (RepoArquivoRNC.original_name.ilike("%.xls")) | + (RepoArquivoRNC.mime.ilike("%spreadsheet%")) | + (RepoArquivoRNC.mime.ilike("%excel%")) + ) + + itens = q.order_by(RepoArquivoRNC.uploaded_at.desc()).all() + if not itens: + st.info("Nenhum arquivo encontrado para os filtros aplicados.") + else: + for item in itens: + with st.expander(f"📄 {item.title or item.original_name} — {item.mime} — {item.size_kb:.1f} KB"): + st.caption( + f"ID: {item.id} | Enviado por: {item.uploaded_by} | Em: {item.uploaded_at.strftime('%d/%m/%Y %H:%M')} | Visível: {'Sim' if item.is_public else 'Não'}" + ) + col_e1, col_e2 = st.columns([1.2, 1]) + novo_titulo = col_e1.text_input("Título", value=item.title or "", key=f"rnc_t_{item.id}") + nova_desc = col_e1.text_area("Descrição", value=item.description or "", key=f"rnc_d_{item.id}", height=80) + novas_tags = col_e1.text_input("Tags (vírgulas)", value=item.tags or "", key=f"rnc_g_{item.id}") + novo_publico = col_e2.checkbox("Público?", value=item.is_public, key=f"rnc_p_{item.id}") + btn_save = col_e2.button("💾 Salvar alterações", key=f"rnc_save_{item.id}") + btn_del = col_e2.button("🗑️ Excluir", key=f"rnc_del_{item.id}") + + st.markdown("---") + if "pdf" in (item.mime or "").lower() or item.original_name.lower().endswith(".pdf"): + _render_pdf_preview(item.storage_path, height=500, key_prefix=f"rnc_adm_{item.id}", default="PDF.js (alternativo)") + elif item.original_name.lower().endswith((".xlsx", ".xls")): + _preview_excel(item.storage_path) + else: + st.info("Preview não disponível para este tipo de arquivo.") + + # Download + try: + with open(item.storage_path, "rb") as f: + st.download_button( + "⬇️ Baixar arquivo", + data=f.read(), + file_name=item.original_name, + mime=item.mime, + key=f"rnc_dl_admin_{item.id}" + ) + except Exception as e: + st.warning(f"Falha ao preparar download: {e}") + + if btn_save: + try: + item.title = (novo_titulo or "").strip() or None + item.description = (nova_desc or "").strip() or None + item.tags = (novas_tags or "").strip() or None + item.is_public = bool(novo_publico) + db.add(item) + db.commit() + st.success("Alterações salvas.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar: {e}") + + if btn_del: + try: + try: + if os.path.exists(item.storage_path): + os.remove(item.storage_path) + except Exception: + pass + db.delete(item) + db.commit() + st.info("Arquivo removido do repositório.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao excluir: {e}") + + # --------------------------- + # USUÁRIO — Consulta/Download + # --------------------------- + st.subheader("🔎 Consulta e Download") + with st.form("form_busca_repo_user_rnc"): + col_u1, col_u2, col_u3 = st.columns([1.8, 1, 1]) + termo_u = col_u1.text_input("Buscar por título/nome/tags:") + tipo_u = col_u2.selectbox("Tipo", ["todos", "pdf", "excel"], index=0) + vis_user = "públicos" if perfil != "admin" else col_u3.selectbox("Visibilidade", ["públicos", "todos"], index=0) + buscar = st.form_submit_button("🔍 Buscar") + + q_user = db.query(RepoArquivoRNC).filter(RepoArquivoRNC.ambiente == ENV_LABEL) + if perfil != "admin" or vis_user == "públicos": + q_user = q_user.filter(RepoArquivoRNC.is_public == True) + if termo_u.strip(): + like_u = f"%{termo_u.strip()}%" + q_user = q_user.filter( + (RepoArquivoRNC.title.ilike(like_u)) | + (RepoArquivoRNC.original_name.ilike(like_u)) | + (RepoArquivoRNC.tags.ilike(like_u)) + ) + if tipo_u == "pdf": + q_user = q_user.filter((RepoArquivoRNC.mime.ilike("%pdf%")) | (RepoArquivoRNC.original_name.ilike("%.pdf"))) + elif tipo_u == "excel": + q_user = q_user.filter( + (RepoArquivoRNC.original_name.ilike("%.xlsx")) | + (RepoArquivoRNC.original_name.ilike("%.xls")) | + (RepoArquivoRNC.mime.ilike("%spreadsheet%")) | + (RepoArquivoRNC.mime.ilike("%excel%")) + ) + + itens_user = q_user.order_by(RepoArquivoRNC.uploaded_at.desc()).all() + if not itens_user: + st.info("Nenhum arquivo encontrado.") + else: + for item in itens_user: + with st.expander(f"📄 {item.title or item.original_name} — {item.mime} — {item.size_kb:.1f} KB"): + st.caption(f"ID: {item.id} | Enviado por: {item.uploaded_by} | Em: {item.uploaded_at.strftime('%d/%m/%Y %H:%M')}") + if "pdf" in (item.mime or "").lower() or item.original_name.lower().endswith(".pdf"): + _render_pdf_preview(item.storage_path, height=450, key_prefix=f"rnc_user_{item.id}", default="PDF.js (alternativo)") + elif item.original_name.lower().endswith((".xlsx", ".xls")): + _preview_excel(item.storage_path) + else: + st.info("Preview não disponível para este tipo.") + + # Download + try: + with open(item.storage_path, "rb") as f: + st.download_button( + "⬇️ Baixar arquivo", + data=f.read(), + file_name=item.original_name, + mime=item.mime, + key=f"rnc_dl_user_{item.id}" + ) + except Exception as e: + st.warning(f"Falha ao preparar download: {e}") + + # Rodapé + st.caption(f"Ambiente: **{ENV_LABEL}** • Pasta: `{_repo_root()}` • Perfil: **{perfil}**") + + # Auditoria (opcional) + try: + from utils_auditoria import registrar_log + registrar_log( + usuario=usuario, + acao=f"Acesso ao Repositório RNC (perfil={perfil})", + tabela="repo_arquivo", + registro_id=None + ) + except Exception: + pass + + +def pagina(): + """Alias para manter compatibilidade com o roteador do app.""" + return main() + diff --git a/repositorio_load.py b/repositorio_load.py new file mode 100644 index 0000000000000000000000000000000000000000..6564df0170a859c30bbbbc3bb34c3d1806eb35a2 --- /dev/null +++ b/repositorio_load.py @@ -0,0 +1,697 @@ + +# -*- coding: utf-8 -*- +import os +import base64 +import hashlib +import mimetypes +from datetime import datetime + +import streamlit as st +import pandas as pd + +from banco import engine, Base, SessionLocal +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Float, Text + +from string import Template # ✅ Evita conflitos com { } em HTML/JS + +# ============================== +# Modelo de dados — repositório +# ============================== +class RepoArquivo(Base): + __tablename__ = "repo_arquivo" + + id = Column(Integer, primary_key=True, autoincrement=True) + ambiente = Column(String(16), nullable=False) # prod/test/etc + storage_path = Column(String(512), nullable=False) # caminho no disco + original_name = Column(String(256), nullable=False) + title = Column(String(256), nullable=True) + description = Column(Text, nullable=True) + tags = Column(String(256), nullable=True) # separado por vírgulas + mime = Column(String(128), nullable=False) + size_kb = Column(Float, nullable=True) + uploaded_by = Column(String(128), nullable=False) + uploaded_at = Column(DateTime, nullable=False, default=datetime.now) + is_public = Column(Boolean, nullable=False, default=True) + +# Cria a tabela se não existir +Base.metadata.create_all(bind=engine) + +# ============================== +# Helpers — paths e segurança +# ============================== +def _current_env_label(): + try: + from db_router import current_db_choice + env = current_db_choice() + return env or "prod" + except Exception: + return "prod" + +def _repo_root(): + """Pasta raiz do repositório por ambiente.""" + env = _current_env_label() + root = os.path.join("data_repo", env) + os.makedirs(root, exist_ok=True) + return root + +def _hash_bytes(b: bytes) -> str: + return hashlib.sha256(b).hexdigest() + +# ✅ MIME por extensão (fallback) +_EXT_MIME = { + ".pdf": "application/pdf", + ".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + ".xls": "application/vnd.ms-excel", +} +def _detect_mime_by_name(original_name: str) -> str: + guessed = mimetypes.guess_type(original_name)[0] + if guessed: + return guessed + ext = os.path.splitext(original_name)[1].lower() + return _EXT_MIME.get(ext, "application/octet-stream") + +def _save_file_to_repo(file_obj, original_name: str) -> tuple[str, float, str]: + """Salva o arquivo e retorna (storage_path, size_kb, mime).""" + content = file_obj.read() + size_kb = round(len(content) / 1024.0, 2) + mime = _detect_mime_by_name(original_name) + root = _repo_root() + ext = os.path.splitext(original_name)[1].lower() + uid = _hash_bytes(content)[:16] + storage_name = f"{uid}{ext}" + storage_path = os.path.join(root, storage_name) + with open(storage_path, "wb") as f: + f.write(content) + return storage_path, size_kb, mime + +# ============================== +# Preview — PDF e Excel +# ============================== +def _pdf_b64_cached(storage_path: str) -> str: + """Cache leve do PDF em base64 na sessão (chaveada por path).""" + key = f"__repo_pdf_b64__::{storage_path}" + if key in st.session_state: + return st.session_state[key] + with open(storage_path, "rb") as f: + b64 = base64.b64encode(f.read()).decode("utf-8") + st.session_state[key] = b64 + return b64 + +def _is_too_big_for_inline(storage_path: str, max_mb: int = 15) -> bool: + """Evita tentar embed de arquivos muito grandes (limite ajustável).""" + try: + return os.path.getsize(storage_path) > max_mb * 1024 * 1024 + except Exception: + return False + +def _button_open_new_tab_blob(storage_path: str, key_prefix: str, label: str = "🧭 Abrir em nova aba"): + """ + Renderiza um botão/link que abre o PDF em nova aba usando blob: (sem embed). + Mais robusto em ambientes com bloqueio de iframe/object. + """ + try: + b64 = _pdf_b64_cached(storage_path) + html_tpl = Template(r""" +
+ + +
+ + """) + html = html_tpl.substitute(K=key_prefix, B64=b64, LABEL=label) + st.components.v1.html(html, height=40) + except Exception as e: + st.warning(f"Não foi possível preparar abertura em nova aba: {e}") + +def _embed_pdf_blob(storage_path: str, height: int = 650, mode: str = "iframe"): + """ + Tenta exibir inline via blob: (iframe ou object). Se não carregar em 3,5s, + mostra status amigável para o usuário. + """ + try: + b64 = _pdf_b64_cached(storage_path) + viewer_iframe = '' + viewer_object = '' + viewer = viewer_object if mode == "object" else viewer_iframe + if mode == "object": + viewer = Template(viewer).substitute(H=int(height)) + + inject = ( + 'var o=document.getElementById("pdfobj"); var e=document.getElementById("pdfemb"); ' + 'if(o){o.setAttribute("data", url); viewerEl=o;} else if(e){e.setAttribute("src", url); viewerEl=e;}' + if mode == "object" + else 'var f=document.getElementById("pdfifr"); if(f){f.setAttribute("src", url); viewerEl=f;}' + ) + + html_tpl = Template(r""" + + + + + + + +
+ carregando visualizador ${MODE}… +
+
+ ${VIEWER} +
+
+ + + + """) + html = html_tpl.substitute( + H=int(height), + VIEWER=viewer, + B64=b64, + MODE=mode, + INJECT=inject + ) + st.components.v1.html(html, height=height + 46, scrolling=False) + except Exception as e: + st.warning(f"Não foi possível exibir o PDF (blob/{mode}): {e}") + +def _embed_pdf_pdfjs(storage_path: str, height: int = 650, theme: str = "dark"): + """PDF.js via CDN (alternativo) — com navegação, zoom e abrir em nova aba (Blob).""" + try: + b64 = _pdf_b64_cached(storage_path) + bg = "#0b1220" if theme == "dark" else "#f8fafc" + fg = "#e2e8f0" if theme == "dark" else "#0f172a" + btn = "#1e293b" if theme == "dark" else "#e2e8f0" + + html_tpl = Template(r""" + + + + + + + +
+ + + Página 1 / ? + + + + + +
+
+ +
+
+ + https://cdnjs.cloudflare.com/ajax/libs/pdf.js/3.11.174/pdf.min.js + + + + """) + html = html_tpl.substitute( + B64=b64, + BG=bg, + FG=fg, + BTN=btn, + VH=max(100, int(height - 54)) + ) + st.components.v1.html(html, height=height + 16, scrolling=False) + except Exception as e: + st.warning(f"Não foi possível exibir o PDF (PDF.js): {e}") + +def _preview_excel(storage_path: str): + """Exibe primeiras linhas de um Excel.""" + try: + path = storage_path.lower() + if path.endswith(".xlsx"): + df = pd.read_excel(storage_path, engine="openpyxl") + elif path.endswith(".xls"): + df = pd.read_excel(storage_path, engine="xlrd") + else: + st.info("Arquivo não reconhecido como Excel.") + return + st.dataframe(df.head(200), use_container_width=True) + except Exception as e: + st.warning(f"Não foi possível ler o Excel: {e}") + +def _render_pdf_preview(storage_path: str, height: int, key_prefix: str, default: str = "PDF.js (alternativo)"): + """ + Viewer por arquivo: + - Botão "Abrir em nova aba" (SEM embed) sempre visível. + - Inline (experimental): PDF.js, Iframe (Blob) ou OBJECT/EMBED (Blob). + - Arquivo grande: bypass para nova aba. + """ + # 1) Sempre oferecer "Abrir em nova aba" (mais confiável) + _button_open_new_tab_blob(storage_path, key_prefix=f"{key_prefix}_open", label="🧭 Abrir em nova aba") + + # 2) Bypass para PDFs muito grandes + if _is_too_big_for_inline(storage_path, max_mb=15): + st.info("📄 PDF grande — recomendado abrir em nova aba (acima).") + return + + # 3) Pré-visualização inline (experimental) + with st.expander("🔬 Pré‑visualização inline (experimental)", expanded=False): + viewer_opt = st.radio( + "Visualizador de PDF:", + options=["PDF.js (alternativo)", "Iframe (Blob)", "OBJECT/EMBED (Blob compat.)"], + index={"PDF.js (alternativo)": 0, "Iframe (Blob)": 1, "OBJECT/EMBED (Blob compat.)": 2}.get(default, 0), + key=f"{key_prefix}_viewer" + ) + if viewer_opt == "PDF.js (alternativo)": + _embed_pdf_pdfjs(storage_path, height=height, theme="dark") + elif viewer_opt == "Iframe (Blob)": + _embed_pdf_blob(storage_path, height=height, mode="iframe") + else: + _embed_pdf_blob(storage_path, height=height, mode="object") + +# ============================== +# UI — módulo +# ============================== +def main(): + st.title("📦 Repositório Load") + st.caption("Admin importa arquivos (Excel/PDF). Usuários consultam online e baixam.") + + perfil = st.session_state.get("perfil", "usuario") + usuario = st.session_state.get("usuario") or "anon" + env = _current_env_label() + db = SessionLocal() + + # --------------------------- + # ADMIN — Upload e gestão + # --------------------------- + if perfil == "admin": + st.subheader("🛠️ Administração do Repositório") + with st.form("form_upload_repo"): + files = st.file_uploader( + "Selecione arquivos (Excel: .xlsx/.xls, PDF: .pdf)", + type=["xlsx", "xls", "pdf"], + accept_multiple_files=True + ) + default_is_public = st.checkbox("Disponibilizar como público", value=True) + tags = st.text_input("Tags (opcional, separadas por vírgula) ex.: 'contas, janeiro, portaria'") + enviar = st.form_submit_button("📤 Enviar para repositório") + + if enviar and files: + ok_count = 0 + for f in files: + try: + storage_path, size_kb, mime = _save_file_to_repo(f, f.name) + registro = RepoArquivo( + ambiente=env, + storage_path=storage_path, + original_name=f.name, + title=os.path.splitext(f.name)[0], + description=None, + tags=(tags or "").strip() or None, + mime=mime, + size_kb=size_kb, + uploaded_by=usuario, + uploaded_at=datetime.now(), + is_public=default_is_public + ) + db.add(registro) + db.commit() + ok_count += 1 + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar '{f.name}': {e}") + if ok_count > 0: + st.success(f"✅ {ok_count} arquivo(s) enviado(s) para o repositório.") + st.rerun() + + with st.expander("📋 Arquivos do repositório (Admin)", expanded=True): + col_s1, col_s2, col_s3 = st.columns([1.8, 1, 1]) + termo = col_s1.text_input("Buscar por título/nome/tags:") + somente_publicos = col_s2.selectbox("Visibilidade", ["todos", "públicos", "privados"], index=0) + tipo_arquivo = col_s3.selectbox("Tipo", ["todos", "pdf", "excel"], index=0) + + q = db.query(RepoArquivo).filter(RepoArquivo.ambiente == env) + if termo.strip(): + like = f"%{termo.strip()}%" + q = q.filter( + (RepoArquivo.title.ilike(like)) | + (RepoArquivo.original_name.ilike(like)) | + (RepoArquivo.tags.ilike(like)) + ) + if somente_publicos != "todos": + q = q.filter(RepoArquivo.is_public == (somente_publicos == "públicos")) + if tipo_arquivo == "pdf": + q = q.filter((RepoArquivo.mime.ilike("%pdf%")) | (RepoArquivo.original_name.ilike("%.pdf"))) + elif tipo_arquivo == "excel": + q = q.filter( + (RepoArquivo.original_name.ilike("%.xlsx")) | + (RepoArquivo.original_name.ilike("%.xls")) | + (RepoArquivo.mime.ilike("%spreadsheet%")) | + (RepoArquivo.mime.ilike("%excel%")) + ) + + itens = q.order_by(RepoArquivo.uploaded_at.desc()).all() + if not itens: + st.info("Nenhum arquivo encontrado para os filtros aplicados.") + else: + for item in itens: + with st.expander(f"📄 {item.title or item.original_name} — {item.mime} — {item.size_kb:.1f} KB"): + st.caption( + f"ID: {item.id} | Enviado por: {item.uploaded_by} | Em: {item.uploaded_at.strftime('%d/%m/%Y %H:%M')} | Visível: {'Sim' if item.is_public else 'Não'}" + ) + col_e1, col_e2 = st.columns([1.2, 1]) + novo_titulo = col_e1.text_input("Título", value=item.title or "", key=f"t_{item.id}") + nova_desc = col_e1.text_area("Descrição", value=item.description or "", key=f"d_{item.id}", height=80) + novas_tags = col_e1.text_input("Tags (vírgulas)", value=item.tags or "", key=f"g_{item.id}") + novo_publico = col_e2.checkbox("Público?", value=item.is_public, key=f"p_{item.id}") + btn_save = col_e2.button("💾 Salvar alterações", key=f"save_{item.id}") + btn_del = col_e2.button("🗑️ Excluir", key=f"del_{item.id}") + + st.markdown("---") + if "pdf" in (item.mime or "").lower() or item.original_name.lower().endswith(".pdf"): + _render_pdf_preview(item.storage_path, height=500, key_prefix=f"adm_{item.id}", default="PDF.js (alternativo)") + elif item.original_name.lower().endswith((".xlsx", ".xls")): + _preview_excel(item.storage_path) + else: + st.info("Preview não disponível para este tipo de arquivo.") + + # Download + try: + with open(item.storage_path, "rb") as f: + st.download_button( + "⬇️ Baixar arquivo", + data=f.read(), + file_name=item.original_name, + mime=item.mime, + key=f"dl_admin_{item.id}" + ) + except Exception as e: + st.warning(f"Falha ao preparar download: {e}") + + if btn_save: + try: + item.title = (novo_titulo or "").strip() or None + item.description = (nova_desc or "").strip() or None + item.tags = (novas_tags or "").strip() or None + item.is_public = bool(novo_publico) + db.add(item) + db.commit() + st.success("Alterações salvas.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar: {e}") + + if btn_del: + try: + try: + if os.path.exists(item.storage_path): + os.remove(item.storage_path) + except Exception: + pass + db.delete(item) + db.commit() + st.info("Arquivo removido do repositório.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao excluir: {e}") + + # --------------------------- + # USUÁRIO — Consulta/Download + # --------------------------- + st.subheader("🔎 Consulta e Download") + with st.form("form_busca_repo_user"): + col_u1, col_u2, col_u3 = st.columns([1.8, 1, 1]) + termo_u = col_u1.text_input("Buscar por título/nome/tags:") + tipo_u = col_u2.selectbox("Tipo", ["todos", "pdf", "excel"], index=0) + vis_user = "públicos" if perfil != "admin" else col_u3.selectbox("Visibilidade", ["públicos", "todos"], index=0) + buscar = st.form_submit_button("🔍 Buscar") + + q_user = db.query(RepoArquivo).filter(RepoArquivo.ambiente == env) + if perfil != "admin" or vis_user == "públicos": + q_user = q_user.filter(RepoArquivo.is_public == True) + if termo_u.strip(): + like_u = f"%{termo_u.strip()}%" + q_user = q_user.filter( + (RepoArquivo.title.ilike(like_u)) | + (RepoArquivo.original_name.ilike(like_u)) | + (RepoArquivo.tags.ilike(like_u)) + ) + if tipo_u == "pdf": + q_user = q_user.filter((RepoArquivo.mime.ilike("%pdf%")) | (RepoArquivo.original_name.ilike("%.pdf"))) + elif tipo_u == "excel": + q_user = q_user.filter( + (RepoArquivo.original_name.ilike("%.xlsx")) | + (RepoArquivo.original_name.ilike("%.xls")) | + (RepoArquivo.mime.ilike("%spreadsheet%")) | + (RepoArquivo.mime.ilike("%excel%")) + ) + + itens_user = q_user.order_by(RepoArquivo.uploaded_at.desc()).all() + if not itens_user: + st.info("Nenhum arquivo encontrado.") + else: + for item in itens_user: + with st.expander(f"📄 {item.title or item.original_name} — {item.mime} — {item.size_kb:.1f} KB"): + st.caption(f"ID: {item.id} | Enviado por: {item.uploaded_by} | Em: {item.uploaded_at.strftime('%d/%m/%Y %H:%M')}") + if "pdf" in (item.mime or "").lower() or item.original_name.lower().endswith(".pdf"): + _render_pdf_preview(item.storage_path, height=450, key_prefix=f"user_{item.id}", default="PDF.js (alternativo)") + elif item.original_name.lower().endswith((".xlsx", ".xls")): + _preview_excel(item.storage_path) + else: + st.info("Preview não disponível para este tipo.") + + # Download + try: + with open(item.storage_path, "rb") as f: + st.download_button( + "⬇️ Baixar arquivo", + data=f.read(), + file_name=item.original_name, + mime=item.mime, + key=f"dl_user_{item.id}" + ) + except Exception as e: + st.warning(f"Falha ao preparar download: {e}") + + # Rodapé + st.caption(f"Ambiente: **{env}** • Pasta: `{_repo_root()}` • Perfil: **{perfil}**") + + # Auditoria (opcional) + try: + from utils_auditoria import registrar_log + registrar_log( + usuario=usuario, + acao=f"Acesso ao Repositório Load (perfil={perfil})", + tabela="repo_arquivo", + registro_id=None + ) + except Exception: + pass diff --git a/requirements.txt b/requirements.txt index d89e22f277a95a8a1d4f9b13c465c33e33d55858..faf6380fdbab6279dc056b3c4ffdbc1f307990ff 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,25 +1,41 @@ ---find-links=https://girder.github.io/large_image_wheels GDAL -# cartopy -fiona -folium #==0.13.0 -# ipywidgets<8.0.5 -geemap -ffmpeg-python -geopandas -# jupyter-server-proxy -# keplergl -leafmap>=0.35.2 -# localtileserver -# nbserverproxy -owslib -palettable -plotly -streamlit -# streamlit-bokeh-events -streamlit-folium -# streamlit-keplergl -# tropycal -# git+https://github.com/giswqs/leafmap -# git+https://github.com/giswqs/geemap -# altair<5 - + +# Sites e APIs +flask==3.0.0 +django==5.0.1 +requests==2.31.0 + +# Ciência de dados e IA +numpy==1.26.3 +pandas==2.2.0 +plotly==5.18.0 +matplotlib==3.8.2 +seaborn==0.13.2 +streamlit==1.30.0 +tensorflow==2.15.0 +keras==2.15.0 +opencv-python==4.9.0.80 +pillow==10.2.0 +scikit-learn==1.4.0 +nltk==3.8.1 +torch==2.1.2 +torchvision==0.16.2 +torchaudio==2.1.2 + +# Automações +selenium==4.16.0 +scrapy==2.11.0 +beautifulsoup4==4.12.2 +pyautogui==0.9.54 +pyodbc==5.0.1 +pywin32==306 + +# Interface gráfica +kivy==2.2.1 +# tkinter já vem com Python (não precisa instalar) +pyqt5==5.15.10 +pygame==2.5.2 + +sqlalchemy +python-dotenv +psycopg2-binary +xlsxwriter diff --git a/resposta.py b/resposta.py new file mode 100644 index 0000000000000000000000000000000000000000..39e3f8c7d5273350baf32557f267e1e462f0cbc0 --- /dev/null +++ b/resposta.py @@ -0,0 +1,250 @@ + +# -*- coding: utf-8 -*- +import streamlit as st +from datetime import datetime +from sqlalchemy import func # ⬅️ para lower() +from models import IOIRunSugestao + +try: + from utils_auditoria import registrar_log +except Exception: + registrar_log = None + +# ========================================================== +# Helper de sessão — ciente do ambiente +# ========================================================== +def _get_db_session(): + try: + from db_router import get_session_for_current_db + return get_session_for_current_db() + except Exception: + pass + try: + from banco import SessionLocal + return SessionLocal() + except Exception as e: + st.error(f"Banco indisponível: {e}") + raise + +# Constantes padronizadas +STATUS_PENDENTE = "pendente" +STATUS_RESPONDIDA = "respondida" + +# Exibe em qual banco estamos (debug seguro) +def _debug_banco_caption(): + try: + from db_router import current_db_choice + st.caption(f"🗄️ (debug) Banco ativo: **{current_db_choice()}**") + except Exception: + st.caption("🗄️ (debug) Banco ativo: **default**") + + +# =============================== +# IOI-RUN — Caixa de Entrada (Admin) + Visualização (Usuário) +# =============================== +def main(): + perfil = st.session_state.get("perfil", "usuario").strip().lower() + usuario_logado = st.session_state.get("usuario") + + # ========================================================== + # MODO USUÁRIO + # ========================================================== + if perfil != "admin": + st.title("📥 Suas respostas do IOI‑RUN") + st.caption("Aqui você vê as respostas enviadas pelo time para suas sugestões.") + _debug_banco_caption() + + if not usuario_logado: + st.info("Faça login para visualizar suas respostas.") + return + + db = _get_db_session() + try: + respostas = ( + db.query(IOIRunSugestao) + .filter( + IOIRunSugestao.usuario == usuario_logado, + func.lower(IOIRunSugestao.status) == STATUS_RESPONDIDA # ⬅️ case-insensitive + ) + .order_by(IOIRunSugestao.data_resposta.desc()) + .all() + ) + + if not respostas: + st.info("Você ainda não possui respostas do IOI‑RUN. Assim que houver, elas aparecerão aqui.") + else: + # Limpa “badge” de novas respostas + try: + st.session_state.user_responses_viewed = True + last_dt = respostas[0].data_resposta if respostas[0].data_resposta else None + if last_dt: + st.session_state["__user_last_answer_seen__"] = last_dt + except Exception: + pass + + for r in respostas: + dt_label = r.data_resposta.strftime('%d/%m/%Y %H:%M') if r.data_resposta else "—" + titulo = f"🗓️ {dt_label} • ID {r.id}" + if r.area: + titulo += f" • Área: {r.area}" + with st.expander(titulo, expanded=False): + st.markdown("**Sua sugestão:**") + st.write(r.mensagem or "—") + st.markdown("---") + st.markdown("**Resposta do time IOI‑RUN:**") + st.success(r.resposta or "—") + rodape = [] + if r.responsavel: + rodape.append(f"por: {r.responsavel}") + if r.data_resposta: + rodape.append(f"em: {r.data_resposta.strftime('%d/%m/%Y %H:%M')}") + if rodape: + st.caption(" | ".join(rodape)) + finally: + try: db.close() + except Exception: pass + + st.markdown("---") + st.caption("Use o **menu lateral** para navegar entre módulos.") + return + + # ========================================================== + # MODO ADMIN + # ========================================================== + st.info("Acesso restrito: somente administradores podem responder sugestões do IOI‑RUN.") + st.title("📬 Caixa de Entrada • IOI‑RUN (Admin)") + st.caption("Responda sugestões dos usuários em uma página dedicada, sem misturar com relatórios.") + _debug_banco_caption() + + # Filtros persistentes + st.session_state.setdefault("resp_area", "todos") + st.session_state.setdefault("resp_status", STATUS_PENDENTE) + st.session_state.setdefault("resp_usuario", "") + st.session_state.setdefault("resp_itens_por_pagina", 0) + st.session_state.setdefault("resp_nonce", 0) + + AREAS = ["todos", "WMS", "FPSO", "UI/UX", "Relatórios", "Integrações", "Performance", "Segurança", "Outros"] + STATUS = [STATUS_PENDENTE, STATUS_RESPONDIDA, "todos"] + + col_f1, col_f2, col_f3, col_f4 = st.columns([1, 1, 1, 0.6]) + col_f1.selectbox("Área/Tema", AREAS, key="resp_area", + index=AREAS.index(st.session_state["resp_area"]) if st.session_state["resp_area"] in AREAS else 0) + col_f2.selectbox("Status", STATUS, key="resp_status", + index=STATUS.index(st.session_state["resp_status"]) if st.session_state["resp_status"] in STATUS else 0) + col_f3.text_input("Filtrar por usuário (login exato)", key="resp_usuario", value=st.session_state["resp_usuario"]) + if col_f4.button("🔄 Atualizar lista"): + st.session_state["resp_nonce"] += 1 + st.rerun() + + db = _get_db_session() + try: + query = db.query(IOIRunSugestao) + if st.session_state["resp_area"] != "todos": + query = query.filter(IOIRunSugestao.area == st.session_state["resp_area"]) + if st.session_state["resp_status"] != "todos": + query = query.filter(func.lower(IOIRunSugestao.status) == st.session_state["resp_status"]) + if (st.session_state["resp_usuario"] or "").strip(): + query = query.filter(IOIRunSugestao.usuario == (st.session_state["resp_usuario"] or "").strip()) + + sugestoes = query.order_by(IOIRunSugestao.data_envio.desc()).all() + except Exception as e: + st.error(f"Erro ao consultar sugestões: {e}") + sugestoes = [] + + if not sugestoes: + st.info("Nenhuma sugestão encontrada para os filtros aplicados.") + else: + for s in sugestoes: + dt_envio = s.data_envio.strftime('%d/%m/%Y %H:%M') if s.data_envio else "—" + titulo = f"📩 {dt_envio} — {s.usuario} — Status: {s.status}" + if s.area: + titulo += f" — Área: {s.area}" + + with st.expander(titulo, expanded=False): + st.markdown("**Sugestão:**") + st.write(s.mensagem or "—") + + with st.form(key=f"form_resp_{s.id}", clear_on_submit=False): + resposta_txt = st.text_area( + f"Responder ao usuário ({s.usuario}) — ID {s.id}", + value=s.resposta or "", + key=f"resp_ioirun_{s.id}", + placeholder="Digite sua resposta para este usuário…", + height=140 + ) + + col_a1, col_a2 = st.columns([1, 1]) + enviar_resp = col_a1.form_submit_button("📤 Enviar resposta") + marcar_pendente = col_a2.form_submit_button("⏳ Marcar como pendente") + + if enviar_resp: + try: + s.resposta = (resposta_txt or "").strip() + s.status = STATUS_RESPONDIDA if s.resposta else STATUS_PENDENTE # ⬅️ normaliza + s.data_resposta = datetime.now() if s.resposta else None + s.responsavel = st.session_state.get("usuario") + + db.add(s) + db.commit() + + # 🔎 DIAGNÓSTICO: reabrir em NOVA sessão e ler do zero + try: + _db_check = _get_db_session() + try: + s_check = _db_check.query(IOIRunSugestao).filter(IOIRunSugestao.id == s.id).first() + st.caption(f"🧪 (debug) Após commit • ID={s.id} • status='{getattr(s_check,'status',None)}' • data_resposta={getattr(s_check,'data_resposta',None)}") + finally: + _db_check.close() + except Exception as _e: + st.caption(f"🧪 (debug) Falha check pós-commit: {_e}") + + if registrar_log and s.resposta: + try: + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"Respondeu sugestão IOI‑RUN (ID {s.id}) para {s.usuario}", + tabela="ioirun_sugestao", + registro_id=s.id + ) + except Exception: + pass + + st.success("Resposta registrada com sucesso! (Ela agora está em 'respondida')") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar resposta: {e}") + + if marcar_pendente: + try: + s.status = STATUS_PENDENTE + s.resposta = None + s.data_resposta = None + s.responsavel = None + db.add(s) + db.commit() + + # 🔎 DIAGNÓSTICO + try: + _db_check = _get_db_session() + try: + s_check = _db_check.query(IOIRunSugestao).filter(IOIRunSugestao.id == s.id).first() + st.caption(f"🧪 (debug) Após pendenciar • ID={s.id} • status='{getattr(s_check,'status',None)}'") + finally: + _db_check.close() + except Exception as _e: + st.caption(f"🧪 (debug) Falha check pós-commit: {_e}") + + st.info("Sugestão marcada como pendente novamente.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao alterar status: {e}") + + st.markdown("---") + st.caption("Use o **menu lateral** para navegar entre módulos.") + + try: db.close() + except Exception: pass + + diff --git a/rnc.py b/rnc.py new file mode 100644 index 0000000000000000000000000000000000000000..a09528bf9f5160e771ee8f97e07d31623fb7c647 --- /dev/null +++ b/rnc.py @@ -0,0 +1,1275 @@ + +# rnc.py +# -*- coding: utf-8 -*- +""" +Módulo: RNC • Registro de Não Conformidades (FOR-SGQ-08 Rev 01) +Recursos: +- Cadastro de RNC com campos do formulário FOR-SGQ-08 (cabeçalho, descrição) +- KPIs (cards) +- Edição de RNC existente (carrega por código ou lista recente) +- Timeline/comentários com atualização de status/prazo/responsável (regras de permissão) +- Plano de Ação (imediata, corretiva, preventiva) – criar e atualizar status/eficácia +- Anexos (upload incremental) +- Auditoria (utils_auditoria.registrar_log) — opcional +- Notificações por e-mail (opcional) se utils_email ou utils_notificacao existir +""" + +import os +from datetime import datetime, date +from typing import Optional, Dict, List + +import streamlit as st + +from banco import SessionLocal +from models import RNC, RNCComentario, RNCAcaoCorretiva, RNCAnexo + + +# =============================== +# Configurações do módulo +# =============================== + +# Diretório para anexos: pode definir via .env (RNC_UPLOAD_DIR) +UPLOAD_DIR = os.getenv("RNC_UPLOAD_DIR", os.path.join("uploads", "rnc")) + +# Controle de permissões +ALLOW_CREATOR_OR_RESP_TO_UPDATE = True # criador ou responsável podem atualizar campos +ONLY_ADMIN_CAN_CLOSE_OR_CANCEL = True # apenas admin pode encerrar/cancelar + +# Status, severidade etc. +STATUS_OPCOES = [ + "Aberta", "Em Análise", "Plano de Ação", + "Implementada", "Verificação", "Encerrada", "Cancelada" +] +SEVERIDADES = ["Crítica", "Maior", "Menor"] +PRIORIDADES = ["Alta", "Média", "Baixa"] +TIPOS = ["Produto", "Processo", "Sistema", "Documentação", "Outro"] + +ORIGENS_FORMS = ["Auditoria Interna", "Auditoria Externa", "Outras"] + + +# =============================== +# Utilidades auxiliares +# =============================== +def _ensure_upload_dir(path: str): + try: + os.makedirs(path, exist_ok=True) + except Exception: + pass + + +def _registrar_log(usuario: Optional[str], acao: str, tabela: str, registro_id: Optional[int] = None): + try: + from utils_auditoria import registrar_log + registrar_log(usuario=usuario, acao=acao, tabela=tabela, registro_id=registro_id) + except Exception: + # fallback silencioso + pass + + +def _send_email_para_responsavel(assunto: str, corpo: str, destinatarios: List[str]): + """Envia e-mail (opcional). Tenta utils_email ou utils_notificacao. Ignora se indisponível.""" + if not destinatarios: + return + try: + from utils_email import send_email # assinatura esperada: (to, subject, body) + for to in destinatarios: + try: + send_email(to, assunto, corpo) + except Exception: + pass + return + except Exception: + pass + + try: + from utils_notificacao import send_email # alternativa + for to in destinatarios: + try: + send_email(to, assunto, corpo) + except Exception: + pass + return + except Exception: + pass + + +def _get_user_email(login: Optional[str]) -> Optional[str]: + """Tenta obter e-mail do usuário em st.session_state.""" + # Ajuste conforme sua infra. Aqui utiliza e-mail da sessão, se existir. + return st.session_state.get("email") + + +def gerar_codigo_rnc(db) -> str: + """Gera código sequencial anual: RNC-YYYY-XXXX (4 dígitos).""" + ano = datetime.utcnow().year + prefixo = f"RNC-{ano}-" + ultimo = ( + db.query(RNC) + .filter(RNC.codigo.like(f"{prefixo}%")) + .order_by(RNC.codigo.desc()) + .first() + ) + if ultimo and ultimo.codigo and ultimo.codigo.startswith(prefixo): + try: + seq = int(ultimo.codigo.split("-")[-1]) + 1 + except Exception: + seq = 1 + else: + seq = 1 + return f"{prefixo}{seq:04d}" + + +def _is_admin(perfil: str) -> bool: + return (perfil or "").lower() == "admin" + + +def can_edit(rnc: RNC, usuario: Optional[str], perfil: str) -> bool: + """Pode editar campos (status/prazo/responsável)?""" + if _is_admin(perfil): + return True + if not ALLOW_CREATOR_OR_RESP_TO_UPDATE: + return False + if not usuario: + return False + return usuario == (rnc.criado_por or "") or usuario == (rnc.responsavel or "") + + +def can_close_or_cancel(perfil: str) -> bool: + """Pode ENCERRAR ou CANCELAR?""" + if ONLY_ADMIN_CAN_CLOSE_OR_CANCEL: + return _is_admin(perfil) + return True + + +# =============================== +# KPIs +# =============================== +def _kpis_area(db, usuario: str, perfil: str): + total = db.query(RNC).count() + em_andamento = db.query(RNC).filter(~RNC.status.in_(["Encerrada", "Cancelada"])).count() + atrasadas = db.query(RNC).filter( + RNC.prazo != None, + RNC.prazo < datetime.utcnow(), + ~RNC.status.in_(["Encerrada", "Cancelada"]) + ).count() + minhas = db.query(RNC).filter( + (RNC.responsavel == usuario) | (RNC.criado_por == usuario) + ).count() + + col1, col2, col3, col4 = st.columns(4) + col1.metric("Total RNC", total) + col2.metric("Em andamento", em_andamento) + col3.metric("Atrasadas", atrasadas, help="Prazo vencido e não encerradas/canceladas") + col4.metric("Minhas (resp./criadas)", minhas) + + +# =============================== +# Formulário: Nova RNC (FOR-SGQ-08) +# =============================== +def _form_nova_rnc(db): + st.subheader("➕ Nova RNC • FOR-SGQ-08") + + # >>> Controles fora do form para permitir re-render dinâmico das linhas de Ações + qtd_ai = st.number_input( + "Quantidade de ações imediatas", + min_value=1, max_value=15, + value=st.session_state.get("__qtd_ai__", 1), + step=1, + help="Ajuste aqui o número de linhas de ações imediatas (Descrição/Responsável/Data)." + ) + st.session_state["__qtd_ai__"] = int(qtd_ai) + + qtd_ac = st.number_input( + "Quantidade de ações CORRETIVAS", + min_value=0, max_value=25, + value=st.session_state.get("__qtd_ac__", 0), + step=1, + help="Defina o número de linhas para Ações Corretivas (Descrição/Responsável/Data)." + ) + st.session_state["__qtd_ac__"] = int(qtd_ac) + + qtd_ap = st.number_input( + "Quantidade de ações PREVENTIVAS", + min_value=0, max_value=25, + value=st.session_state.get("__qtd_ap__", 0), + step=1, + help="Defina o número de linhas para Ações Preventivas (Descrição/Responsável/Data)." + ) + st.session_state["__qtd_ap__"] = int(qtd_ap) + + with st.form("form_nova_rnc"): + st.markdown("#### Cabeçalho do Formulário") + col_h1, col_h2, col_h3 = st.columns([1, 1, 1]) + data_form = col_h1.date_input("DATA", value=date.today(), format="DD/MM/YYYY") + emitente = col_h2.text_input("EMITENTE") + rnc_cliente_numero = col_h3.text_input("RNC CLIENTE Nº (opcional)", placeholder="N/A") + + col_h4, col_h5 = st.columns([1, 1]) + cliente_emitente = col_h4.text_input("CLIENTE EMITENTE (opcional)", placeholder="N/A") + origem_form = col_h5.selectbox("Origem", ORIGENS_FORMS, index=2) # padrão: Outras + + st.markdown("##### Envolvidos") + col_e1, col_e2, col_e3 = st.columns([2, 1, 1]) + inv1_nome = col_e1.text_input("Envolvido 1 — Nome", placeholder="Ex.: Andreia Araújo") + inv1_matr = col_e2.text_input("Matrícula 1", placeholder="") + inv1_func = col_e3.text_input("Função 1", placeholder="Ex.: Operations Leader") + + col_e4, col_e5, col_e6 = st.columns([2, 1, 1]) + inv2_nome = col_e4.text_input("Envolvido 2 — Nome", placeholder="") + inv2_matr = col_e5.text_input("Matrícula 2", placeholder="") + inv2_func = col_e6.text_input("Função 2", placeholder="") + + col_h6, col_h7 = st.columns([1, 1]) + area_solicitante = col_h6.text_input("Área Solicitante", placeholder="Ex.: Operacional SBM") + area_notificada = col_h7.text_input("Área Notificada", placeholder="Ex.: QSMS") + + st.markdown("---") + st.markdown("#### Classificação e Contexto") + col_c1, col_c2, col_c3 = st.columns([1, 1, 1]) + tipo = col_c1.selectbox("Tipo", TIPOS, index=0) + severidade = col_c2.selectbox("Severidade", SEVERIDADES, index=1) + prioridade = col_c3.selectbox("Prioridade", PRIORIDADES, index=1) + + col_c4, col_c5 = st.columns([1, 1]) + cliente = col_c4.text_input("Cliente (opcional)") + local = col_c5.text_input("Local (opcional)") + + st.markdown("---") + st.markdown("#### Descrição da Não Conformidade") + descricao_nc = st.text_area("Descrição detalhada", height=160) + + st.markdown("---") + st.markdown("#### Ação Imediata/Contenção (linha avulsa / legado)") + ac_imediata_desc = st.text_area("Ação Imediata/Contenção (disposição quando aplicável)", height=120) + col_ai1, col_ai2 = st.columns([1, 1]) + ac_imediata_resp = col_ai1.text_input("Responsável pela Ação Imediata") + ac_imediata_data = col_ai2.date_input("Data de Conclusão (Ação Imediata)", value=None, format="DD/MM/YYYY") + + # >>> Linha avulsa (legado) + st.markdown("#### Ação Corretiva (linha avulsa / legado)") + ac_corretiva_desc = st.text_area("Ação Corretiva (quando aplicável)", height=120) + col_cx1, col_cx2 = st.columns([1, 1]) + ac_corretiva_resp = col_cx1.text_input("Responsável pela Ação Corretiva") + ac_corretiva_data = col_cx2.date_input("Data de Conclusão (Ação Corretiva)", value=None, format="DD/MM/YYYY") + + st.markdown("#### Ação Preventiva (linha avulsa / legado)") + ac_preventiva_desc = st.text_area("Ação Preventiva (quando aplicável)", height=120) + col_px1, col_px2 = st.columns([1, 1]) + ac_preventiva_resp = col_px1.text_input("Responsável pela Ação Preventiva") + ac_preventiva_data = col_px2.date_input("Data de Conclusão (Ação Preventiva)", value=None, format="DD/MM/YYYY") + + # >>> Múltiplas linhas + st.markdown("---") + st.markdown("#### Ações Imediatas/Contenção (múltiplas linhas)") + if "__ai_rows__" not in st.session_state: + st.session_state["__ai_rows__"] = {} + N = st.session_state.get("__qtd_ai__", 1) + for idx in range(N): + st.caption(f"— Ação imediata #{idx+1}") + col_m1, col_m2, col_m3 = st.columns([2, 1, 1]) + prev = st.session_state["__ai_rows__"].get(idx, {}) + desc_val = prev.get("desc", "") + resp_val = prev.get("resp", "") + date_val = prev.get("date", None) + desc_i = col_m1.text_input(f"Descrição da ação #{idx+1}", value=desc_val, key=f"ai_desc_{idx}") + resp_i = col_m2.text_input(f"Responsável #{idx+1}", value=resp_val, key=f"ai_resp_{idx}") + date_i = col_m3.date_input(f"Data conclusão #{idx+1}", value=date_val, format="DD/MM/YYYY", key=f"ai_date_{idx}") + st.session_state["__ai_rows__"][idx] = {"desc": desc_i, "resp": resp_i, "date": date_i} + + st.markdown("---") + st.markdown("#### Ações Corretivas (múltiplas linhas)") + if "__ac_rows__" not in st.session_state: + st.session_state["__ac_rows__"] = {} + NC = st.session_state.get("__qtd_ac__", 0) + for idx in range(NC): + st.caption(f"— Ação corretiva #{idx+1}") + col_c1, col_c2, col_c3 = st.columns([2, 1, 1]) + prevc = st.session_state["__ac_rows__"].get(idx, {}) + desc_val = prevc.get("desc", "") + resp_val = prevc.get("resp", "") + date_val = prevc.get("date", None) + desc_i = col_c1.text_input(f"Descrição da ação corretiva #{idx+1}", value=desc_val, key=f"ac_desc_{idx}") + resp_i = col_c2.text_input(f"Responsável (corretiva) #{idx+1}", value=resp_val, key=f"ac_resp_{idx}") + date_i = col_c3.date_input(f"Data conclusão (corretiva) #{idx+1}", value=date_val, format="DD/MM/YYYY", key=f"ac_date_{idx}") + st.session_state["__ac_rows__"][idx] = {"desc": desc_i, "resp": resp_i, "date": date_i} + + st.markdown("---") + st.markdown("#### Ações Preventivas (múltiplas linhas)") + if "__ap_rows__" not in st.session_state: + st.session_state["__ap_rows__"] = {} + NP = st.session_state.get("__qtd_ap__", 0) + for idx in range(NP): + st.caption(f"— Ação preventiva #{idx+1}") + col_p1, col_p2, col_p3 = st.columns([2, 1, 1]) + prevp = st.session_state["__ap_rows__"].get(idx, {}) + desc_val = prevp.get("desc", "") + resp_val = prevp.get("resp", "") + date_val = prevp.get("date", None) + desc_i = col_p1.text_input(f"Descrição da ação preventiva #{idx+1}", value=desc_val, key=f"ap_desc_{idx}") + resp_i = col_p2.text_input(f"Responsável (preventiva) #{idx+1}", value=resp_val, key=f"ap_resp_{idx}") + date_i = col_p3.date_input(f"Data conclusão (preventiva) #{idx+1}", value=date_val, format="DD/MM/YYYY", key=f"ap_date_{idx}") + st.session_state["__ap_rows__"][idx] = {"desc": desc_i, "resp": resp_i, "date": date_i} + + st.markdown("---") + st.markdown("#### Análise das Causas") + metodologia = st.selectbox( + "Metodologia utilizada", + ["Ishikawa (Diagrama de Causa e Efeito)", "5 Porquês", "FMEA", "Análise de Processo", "Outra"], + index=0 + ) + causa_raiz = st.text_area("Causa Raiz Identificada", height=120) + + with st.expander("Metodologia Ishikawa (opcional) — preencher 1ª, 2ª, 3ª, 4ª por categoria"): + st.caption("Preencha quando a metodologia for Ishikawa. Caso contrário, deixe em branco.") + def ishikawa_inputs(label_prefix): + col_i1, col_i2 = st.columns([1, 1]) + a1 = col_i1.text_input(f"{label_prefix} — 1ª") + a2 = col_i2.text_input(f"{label_prefix} — 2ª") + col_i3, col_i4 = st.columns([1, 1]) + a3 = col_i3.text_input(f"{label_prefix} — 3ª") + a4 = col_i4.text_input(f"{label_prefix} — 4ª") + return [a1, a2, a3, a4] + + pessoa = ishikawa_inputs("Mão de Obra - Pessoa") + material = ishikawa_inputs("Material") + medida = ishikawa_inputs("Medida") + meio_ambiente = ishikawa_inputs("Meio Ambiente") + maquina = ishikawa_inputs("Máquina ou Equipamento") + metodo = ishikawa_inputs("Método") + + anexos = st.file_uploader("Anexos (opcional)", type=None, accept_multiple_files=True) + + enviar = st.form_submit_button("Salvar RNC") + + if enviar: + if not emitente or not descricao_nc: + st.warning("Preencha ao menos **Emitente** e **Descrição da Não Conformidade**.") + return + + try: + codigo = gerar_codigo_rnc(db) + + # Cabeçalho estruturado (persistido em 'descricao' antes do texto da NC) + header_md = f"""**FOR-SGQ-08 • Rev 01** +**DATA:** {data_form.strftime('%d/%m/%Y')} • **EMITENTE:** {emitente} • **RNC Nº:** {codigo} +**RNC CLIENTE Nº:** {rnc_cliente_numero or 'N/A'} • **CLIENTE EMITENTE:** {cliente_emitente or 'N/A'} +**Área Solicitante:** {area_solicitante or '—'} • **Origem:** {origem_form} • **Área Notificada:** {area_notificada or '—'} +**Envolvidos:** +- {inv1_nome or '—'} • Matr.: {inv1_matr or '—'} • Função: {inv1_func or '—'} +- {inv2_nome or '—'} • Matr.: {inv2_matr or '—'} • Função: {inv2_func or '—'} + +**Descrição da Não Conformidade:** +{descricao_nc.strip()} +""" + + # Bloco de causa raiz / metodologia (persistido em 'causa_raiz') + causa_md = f"""**Metodologia utilizada:** {metodologia} +**Causa Raiz Identificada:** +{(causa_raiz or '').strip() or '—'} +""" + + def _format_cat(nome, vals): + filas = [v for v in (vals or []) if (v or "").strip()] + if not filas: + return "" + lines = "\n".join([f"- {i+1}ª: {filas[i]}" for i in range(len(filas))]) + return f"**{nome}:**\n{lines}\n" + + ish_md_parts = [] + ish_md_parts.append(_format_cat("Mão de Obra - Pessoa", pessoa)) + ish_md_parts.append(_format_cat("Material", material)) + ish_md_parts.append(_format_cat("Medida", medida)) + ish_md_parts.append(_format_cat("Meio Ambiente", meio_ambiente)) + ish_md_parts.append(_format_cat("Máquina ou Equipamento", maquina)) + ish_md_parts.append(_format_cat("Método", metodo)) + ish_md = "\n".join([p for p in ish_md_parts if p]) + + if ish_md.strip(): + causa_md += "\n**Ishikawa (Diagrama de Causa e Efeito):**\n" + ish_md + + # Cria RNC + rnc = RNC( + codigo=codigo, + titulo=f"RNC {codigo} • {emitente}", + descricao=header_md, # cabeçalho + descrição NC + origem=origem_form, + tipo=(tipo or "").strip() or None, + severidade=(severidade or "").strip() or None, + prioridade=(prioridade or "").strip() or None, + status="Aberta", + data_abertura=datetime.utcnow(), + prazo=None, + responsavel=None, # pode ser definido depois + area_solicitante=(area_solicitante or "").strip() or None, + area_notificada=(area_notificada or "").strip() or None, + criado_por=st.session_state.get("usuario") or "desconhecido", + cliente=(cliente or "").strip() or None, + local=(local or "").strip() or None, + causa_raiz=causa_md, # bloco estruturado + emitente=emitente, + data_form=data_form, + rnc_cliente_numero=(rnc_cliente_numero or "").strip() or None, + cliente_emitente=(cliente_emitente or "").strip() or None + ) + db.add(rnc) + db.commit() + + # Ação Imediata/Contenção (LEGADO — uma linha) + if (ac_imediata_desc or "").strip(): + try: + ac = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[IMEDIATA/CONTENÇÃO] {ac_imediata_desc.strip()}", + responsavel=(ac_imediata_resp or "").strip() or None, + prazo=datetime.combine(ac_imediata_data, datetime.min.time()) if isinstance(ac_imediata_data, date) else None, + status="Concluída" if isinstance(ac_imediata_data, date) else "Em execução", + conclusao_em=datetime.combine(ac_imediata_data, datetime.min.time()) if isinstance(ac_imediata_data, date) else None, + eficacia=None + ) + db.add(ac) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar a Ação Imediata (linha única): {e}") + + # Ação Corretiva (LEGADO — uma linha) + if (ac_corretiva_desc or "").strip(): + try: + acx = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[Corretiva] {ac_corretiva_desc.strip()}", + responsavel=(ac_corretiva_resp or "").strip() or None, + prazo=datetime.combine(ac_corretiva_data, datetime.min.time()) if isinstance(ac_corretiva_data, date) else None, + status="Concluída" if isinstance(ac_corretiva_data, date) else "Em execução", + conclusao_em=datetime.combine(ac_corretiva_data, datetime.min.time()) if isinstance(ac_corretiva_data, date) else None, + eficacia=None + ) + db.add(acx) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar a Ação Corretiva (linha única): {e}") + + # Ação Preventiva (LEGADO — uma linha) + if (ac_preventiva_desc or "").strip(): + try: + apx = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[Preventiva] {ac_preventiva_desc.strip()}", + responsavel=(ac_preventiva_resp or "").strip() or None, + prazo=datetime.combine(ac_preventiva_data, datetime.min.time()) if isinstance(ac_preventiva_data, date) else None, + status="Concluída" if isinstance(ac_preventiva_data, date) else "Em execução", + conclusao_em=datetime.combine(ac_preventiva_data, datetime.min.time()) if isinstance(ac_preventiva_data, date) else None, + eficacia=None + ) + db.add(apx) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar a Ação Preventiva (linha única): {e}") + + # salvar múltiplas Ações Imediatas/Contenção + try: + ac_rows = st.session_state.get("__ai_rows__", {}) + N = st.session_state.get("__qtd_ai__", 1) + if ac_rows: + for idx in range(N): + row = ac_rows.get(idx, {}) + desc = (row.get("desc") or "").strip() + resp = (row.get("resp") or "").strip() or None + dt = row.get("date", None) + if desc: + ac = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[IMEDIATA/CONTENÇÃO] {desc}", + responsavel=resp, + prazo=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + status="Concluída" if isinstance(dt, date) else "Em execução", + conclusao_em=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + eficacia=None + ) + db.add(ac) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar ações imediatas (múltiplas): {e}") + + # salvar múltiplas Ações CORRETIVAS + try: + ac_rows2 = st.session_state.get("__ac_rows__", {}) + NC = st.session_state.get("__qtd_ac__", 0) + if ac_rows2 and NC > 0: + for idx in range(NC): + row = ac_rows2.get(idx, {}) + desc = (row.get("desc") or "").strip() + resp = (row.get("resp") or "").strip() or None + dt = row.get("date", None) + if desc: + acor = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[Corretiva] {desc}", + responsavel=resp, + prazo=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + status="Concluída" if isinstance(dt, date) else "Em execução", + conclusao_em=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + eficacia=None + ) + db.add(acor) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar ações corretivas (múltiplas): {e}") + + # salvar múltiplas Ações PREVENTIVAS + try: + ap_rows2 = st.session_state.get("__ap_rows__", {}) + NP = st.session_state.get("__qtd_ap__", 0) + if ap_rows2 and NP > 0: + for idx in range(NP): + row = ap_rows2.get(idx, {}) + desc = (row.get("desc") or "").strip() + resp = (row.get("resp") or "").strip() or None + dt = row.get("date", None) + if desc: + apre = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[Preventiva] {desc}", + responsavel=resp, + prazo=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + status="Concluída" if isinstance(dt, date) else "Em execução", + conclusao_em=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + eficacia=None + ) + db.add(apre) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar ações preventivas (múltiplas): {e}") + + # Anexos + if anexos: + dest_dir = os.path.join(UPLOAD_DIR, rnc.codigo) + _ensure_upload_dir(dest_dir) + for up in anexos: + dest_path = os.path.join(dest_dir, up.name) + with open(dest_path, "wb") as f: + f.write(up.getbuffer()) + anexo = RNCAnexo( + rnc_id=rnc.id, + nome_arquivo=up.name, + caminho=dest_path, + conteudo_tipo=getattr(up, "type", None), + enviado_por=st.session_state.get("usuario"), + enviado_em=datetime.utcnow() + ) + db.add(anexo) + db.commit() + + # Auditoria + _registrar_log(st.session_state.get("usuario"), f"Criou RNC {rnc.codigo}", "rnc", rnc.id) + + # Notificação (opcional) + emails = [] + criador_email = _get_user_email(rnc.criado_por) + if criador_email: + emails.append(criador_email) + assunto = f"[IOI-RUN] Nova RNC criada: {rnc.codigo}" + corpo = f"""Uma nova RNC foi criada. + +Código: {rnc.codigo} +Emitente: {emitente} +Origem: {origem_form} +Tipo: {rnc.tipo or '—'} | Severidade: {rnc.severidade or '—'} | Prioridade: {rnc.prioridade or '—'} +Área Solicitante: {area_solicitante or '—'} | Área Notificada: {area_notificada or '—'} +Cliente: {rnc.cliente or '—'} | Local: {rnc.local or '—'} +Abertura: {rnc.data_abertura.strftime('%d/%m/%Y %H:%M')} + +Descrição da NC: +{descricao_nc.strip()} +""" + _send_email_para_responsavel(assunto, corpo, emails) + + st.success(f"RNC **{rnc.codigo}** criada com sucesso!") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar a RNC: {e}") + + +# =============================== +# EDIÇÃO de RNC existente +# =============================== +def _extrair_descricao_nc(descricao_md: str) -> str: + """Tenta extrair o corpo após o marcador '**Descrição da Não Conformidade:**'.""" + if not descricao_md: + return "" + marker = "**Descrição da Não Conformidade:**" + if marker in descricao_md: + try: + return descricao_md.split(marker, 1)[1].strip() + except Exception: + return descricao_md + return descricao_md + + +def _montar_descricao_md(data_form: date, emitente: str, codigo: str, + rnc_cliente_numero: Optional[str], cliente_emitente: Optional[str], + area_solicitante: Optional[str], origem_form: str, area_notificada: Optional[str], + inv1: Dict[str, str], inv2: Dict[str, str], + descricao_nc: str) -> str: + header_md = f"""**FOR-SGQ-08 • Rev 01** +**DATA:** {data_form.strftime('%d/%m/%Y')} • **EMITENTE:** {emitente} • **RNC Nº:** {codigo} +**RNC CLIENTE Nº:** {rnc_cliente_numero or 'N/A'} • **CLIENTE EMITENTE:** {cliente_emitente or 'N/A'} +**Área Solicitante:** {area_solicitante or '—'} • **Origem:** {origem_form} • **Área Notificada:** {area_notificada or '—'} +**Envolvidos:** +- {inv1.get('nome') or '—'} • Matr.: {inv1.get('matr') or '—'} • Função: {inv1.get('func') or '—'} +- {inv2.get('nome') or '—'} • Matr.: {inv2.get('matr') or '—'} • Função: {inv2.get('func') or '—'} + +**Descrição da Não Conformidade:** +{(descricao_nc or '').strip()} +""" + return header_md + + +def _editar_rnc(db): + st.subheader("✏️ Editar RNC existente") + + # Controles de busca + col_s1, col_s2, col_s3, col_s4 = st.columns([1.2, 1.6, 0.6, 0.6]) + cod_informado = col_s1.text_input("Informe o código (ex.: RNC-2026-0001)", key="__rnc_edit_code__") + # últimas 30 + recentes = db.query(RNC).order_by(RNC.data_abertura.desc()).limit(30).all() + opts = [f"{r.codigo} — {r.titulo or '—'}" for r in recentes] + sel = col_s2.selectbox("ou selecione uma RNC recente", ["(nenhuma)"] + opts, index=0) + trigger = col_s3.button("Carregar") + limpar = col_s4.button("Limpar seleção") + + # Botão limpar: NÃO alterar __rnc_edit_code__ para evitar erro do Streamlit + if limpar: + st.session_state.pop("__rnc_edit_id__", None) + st.rerun() + + # Botão carregar + if trigger: + # Se "(nenhuma)" e sem código — limpar seleção anterior + if sel == "(nenhuma)" and not (cod_informado or "").strip(): + st.session_state.pop("__rnc_edit_id__", None) + st.info("Nenhuma RNC selecionada. A seleção foi limpa.") + st.rerun() + + # Resolve código digitado ou selecionado + codigo = (cod_informado or "").strip() or (sel.split(" — ")[0] if sel != "(nenhuma)" else "") + alvo = db.query(RNC).filter(RNC.codigo == codigo).first() if codigo else None + + if not alvo: + st.session_state.pop("__rnc_edit_id__", None) + st.warning("RNC não encontrada ou nenhuma selecionada. A seleção foi limpa.") + st.rerun() + else: + st.session_state["__rnc_edit_id__"] = alvo.id + st.success(f"RNC {alvo.codigo} carregada para edição.") + st.rerun() + + # RNC carregada + rnc = db.query(RNC).filter(RNC.id == st.session_state.get("__rnc_edit_id__")).first() if st.session_state.get("__rnc_edit_id__") else None + + if not rnc: + st.info("Carregue uma RNC para editar.") + return + + # Form de edição + with st.form(f"form_edit_rnc_{rnc.id}"): + st.markdown(f"**Editando:** {rnc.codigo} • {rnc.titulo or '—'}") + + col_eh1, col_eh2, col_eh3 = st.columns([1, 1, 1]) + data_form = col_eh1.date_input("DATA", value=rnc.data_form or date.today(), format="DD/MM/YYYY") + emitente = col_eh2.text_input("EMITENTE", value=rnc.emitente or "") + origem_form = col_eh3.selectbox("Origem", ORIGENS_FORMS, index=(ORIGENS_FORMS.index(rnc.origem) if rnc.origem in ORIGENS_FORMS else 2)) + + col_eh4, col_eh5 = st.columns([1, 1]) + rnc_cliente_numero = col_eh4.text_input("RNC CLIENTE Nº (opcional)", value=rnc.rnc_cliente_numero or "") + cliente_emitente = col_eh5.text_input("CLIENTE EMITENTE (opcional)", value=rnc.cliente_emitente or "") + + col_h6, col_h7 = st.columns([1, 1]) + area_solicitante = col_h6.text_input("Área Solicitante", value=rnc.area_solicitante or "") + area_notificada = col_h7.text_input("Área Notificada", value=rnc.area_notificada or "") + + st.markdown("##### Classificação") + col_c1, col_c2, col_c3 = st.columns([1, 1, 1]) + tipo = col_c1.selectbox("Tipo", TIPOS, index=(TIPOS.index(rnc.tipo) if rnc.tipo in TIPOS else 0)) + severidade = col_c2.selectbox("Severidade", SEVERIDADES, index=(SEVERIDADES.index(rnc.severidade) if rnc.severidade in SEVERIDADES else 1)) + prioridade = col_c3.selectbox("Prioridade", PRIORIDADES, index=(PRIORIDADES.index(rnc.prioridade) if rnc.prioridade in PRIORIDADES else 1)) + + col_c4, col_c5 = st.columns([1, 1]) + cliente = col_c4.text_input("Cliente (opcional)", value=rnc.cliente or "") + local = col_c5.text_input("Local (opcional)", value=rnc.local or "") + + st.markdown("##### Atribuição e Prazo") + col_as1, col_as2, col_as3 = st.columns([1, 1, 1]) + responsavel = col_as1.text_input("Responsável", value=rnc.responsavel or "") + prazo = col_as2.date_input("Prazo (conclusão)", value=(rnc.prazo.date() if isinstance(rnc.prazo, datetime) else rnc.prazo) if rnc.prazo else None, format="DD/MM/YYYY") + status = col_as3.selectbox("Status", STATUS_OPCOES, index=(STATUS_OPCOES.index(rnc.status) if rnc.status in STATUS_OPCOES else 0)) + + st.markdown("##### Descrição e Causa Raiz") + # tenta extrair apenas a descrição da NC a partir do markdown original + desc_nc_atual = _extrair_descricao_nc(rnc.descricao or "") + descricao_nc = st.text_area("Descrição da Não Conformidade (substituir corpo)", value=desc_nc_atual or "", height=140) + causa_raiz = st.text_area("Causa Raiz Identificada", value=rnc.causa_raiz or "", height=120) + + # Envolvidos (mantemos inputs simples; no cadastro ficam no cabeçalho) + with st.expander("Envolvidos (opcional)"): + col_en1, col_en2, col_en3 = st.columns([2, 1, 1]) + inv1_nome = col_en1.text_input("Envolvido 1 — Nome", value="") + inv1_matr = col_en2.text_input("Matrícula 1", value="") + inv1_func = col_en3.text_input("Função 1", value="") + + col_en4, col_en5, col_en6 = st.columns([2, 1, 1]) + inv2_nome = col_en4.text_input("Envolvido 2 — Nome", value="") + inv2_matr = col_en5.text_input("Matrícula 2", value="") + inv2_func = col_en6.text_input("Função 2", value="") + + salvar = st.form_submit_button("💾 Salvar alterações") + + if salvar: + usuario = st.session_state.get("usuario") + perfil = (st.session_state.get("perfil") or "user").lower() + + # Permissão para Encerrar/Cancelar + if status in ["Encerrada", "Cancelada"] and not can_close_or_cancel(perfil): + st.warning("Somente administradores podem **encerrar** ou **cancelar** RNC.") + return + + try: + # Atualiza campos básicos + rnc.emitente = (emitente or "").strip() or None + rnc.origem = origem_form + rnc.rnc_cliente_numero = (rnc_cliente_numero or "").strip() or None + rnc.cliente_emitente = (cliente_emitente or "").strip() or None + rnc.area_solicitante = (area_solicitante or "").strip() or None + rnc.area_notificada = (area_notificada or "").strip() or None + rnc.tipo = (tipo or "").strip() or None + rnc.severidade = (severidade or "").strip() or None + rnc.prioridade = (prioridade or "").strip() or None + rnc.cliente = (cliente or "").strip() or None + rnc.local = (local or "").strip() or None + rnc.responsavel = (responsavel or "").strip() or None + rnc.prazo = datetime.combine(prazo, datetime.min.time()) if isinstance(prazo, date) else None + rnc.status = status + rnc.data_form = data_form + rnc.titulo = f"RNC {rnc.codigo} • {emitente or '—'}" + rnc.causa_raiz = (causa_raiz or "").strip() or None + + # Reconstrói o markdown de 'descricao' no mesmo padrão do cadastro + inv1 = {"nome": inv1_nome, "matr": inv1_matr, "func": inv1_func} + inv2 = {"nome": inv2_nome, "matr": inv2_matr, "func": inv2_func} + rnc.descricao = _montar_descricao_md( + data_form=data_form, + emitente=emitente, + codigo=rnc.codigo, + rnc_cliente_numero=rnc_cliente_numero, + cliente_emitente=cliente_emitente, + area_solicitante=area_solicitante, + origem_form=origem_form, + area_notificada=area_notificada, + inv1=inv1, + inv2=inv2, + descricao_nc=descricao_nc, + ) + + # Encerramento: registra data + if status == "Encerrada": + rnc.encerrada_em = datetime.utcnow() + elif status != "Encerrada": + rnc.encerrada_em = None + + db.add(rnc) + db.commit() + + _registrar_log(usuario, f"Editou RNC {rnc.codigo}", "rnc", rnc.id) + st.success("RNC atualizada com sucesso!") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar alterações: {e}") + + +# =============================== +# Anexos / Plano de Ação / Timeline / Aprovação +# =============================== +def _mostrar_anexos(rnc: RNC, db): + st.markdown("**Anexos**") + if rnc.anexos: + for an in rnc.anexos: + st.caption(f"📎 {an.nome_arquivo} • {an.enviado_por or '—'} • {an.enviado_em.strftime('%d/%m/%Y %H:%M')}") + if os.path.exists(an.caminho): + try: + with open(an.caminho, "rb") as f: + st.download_button(f"Baixar {an.nome_arquivo}", f.read(), file_name=an.nome_arquivo, key=f"dl_{rnc.id}_{an.id}") + except Exception: + st.caption("Arquivo indisponível.") + else: + st.caption("Sem anexos") + + up_more = st.file_uploader(f"Adicionar anexos — {rnc.codigo}", accept_multiple_files=True, key=f"up_{rnc.id}") + if up_more: + dest_dir = os.path.join(UPLOAD_DIR, rnc.codigo) + _ensure_upload_dir(dest_dir) + try: + for up in up_more: + dest_path = os.path.join(dest_dir, up.name) + with open(dest_path, "wb") as f: + f.write(up.getbuffer()) + anexo = RNCAnexo( + rnc_id=rnc.id, + nome_arquivo=up.name, + caminho=dest_path, + conteudo_tipo=getattr(up, "type", None), + enviado_por=st.session_state.get("usuario"), + enviado_em=datetime.utcnow() + ) + db.add(anexo) + db.commit() + st.success("Anexos adicionados.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar anexos: {e}") + + +def _mostrar_plano_acao(rnc: RNC, db): + st.markdown("### 🛠️ Plano de Ação — Separado por categorias") + + def _cat(descricao: str) -> str: + s = (descricao or "").strip() + if s.startswith("[IMEDIATA/CONTENÇÃO]"): + return "imediata" + elif s.startswith("[Preventiva]"): + return "preventiva" + elif s.startswith("[Corretiva]"): + return "corretiva" + else: + return "outras" + + acoes = rnc.acoes or [] + acoes_imediatas = [a for a in acoes if _cat(a.descricao) == "imediata"] + acoes_corretivas = [a for a in acoes if _cat(a.descricao) == "corretiva"] + acoes_preventivas = [a for a in acoes if _cat(a.descricao) == "preventiva"] + acoes_outras = [a for a in acoes if _cat(a.descricao) == "outras"] + + def _render_bloco(titulo: str, lista: List[RNCAcaoCorretiva]): + st.markdown(f"#### {titulo}") + if not lista: + st.caption("— Nenhuma ação cadastrada nesta categoria.") + return + lista = sorted(lista, key=lambda a: (a.status, a.prazo or datetime.max)) + for ac in lista: + line = f"- {ac.descricao}" + line += f" • Resp: {ac.responsavel or '—'}" + line += f" • Prazo: {ac.prazo.strftime('%d/%m/%Y') if ac.prazo else '—'}" + line += f" • Status: {ac.status}" + if ac.eficacia: + line += f" • Eficácia: {ac.eficacia}" + st.write(line) + + with st.expander(f"Atualizar ação • ID {ac.id}", expanded=False): + col_s1, col_s2, col_s3 = st.columns(3) + novo_status = col_s1.selectbox( + "Status", + ["Planejada", "Em execução", "Concluída", "Ineficaz"], + index=["Planejada", "Em execução", "Concluída", "Ineficaz"].index(ac.status) + if ac.status in ["Planejada", "Em execução", "Concluída", "Ineficaz"] else 0, + key=f"ac_st_{ac.id}" + ) + nova_eficacia = col_s2.selectbox( + "Eficácia (resultado da ação)", + ["(não avaliada)", "Eficaz", "Parcial", "Ineficaz"], + index=0 if not ac.eficacia else ["(não avaliada)", "Eficaz", "Parcial", "Ineficaz"].index( + ac.eficacia if ac.eficacia in ["Eficaz", "Parcial", "Ineficaz"] else "(não avaliada)" + ), + key=f"ac_eff_{ac.id}" + ) + concluir = col_s3.button("Salvar atualização", key=f"ac_sv_{ac.id}") + + if concluir: + try: + ac.status = novo_status + if novo_status == "Concluída": + ac.conclusao_em = datetime.utcnow() + ac.eficacia = None if nova_eficacia == "(não avaliada)" else nova_eficacia + db.add(ac) + db.commit() + _registrar_log(st.session_state.get("usuario"), f"Atualizou ação RNC {rnc.codigo} (ID ação {ac.id})", "rnc_acao", ac.id) + st.success("Ação atualizada.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao atualizar ação: {e}") + + _render_bloco("🚀 Ações Imediatas/Contenção", acoes_imediatas) + _render_bloco("🔧 Ações Corretivas", acoes_corretivas) + _render_bloco("🛡️ Ações Preventivas", acoes_preventivas) + _render_bloco("📁 Outras (sem prefixo)", acoes_outras) + + # Form nova ação + with st.form(f"form_new_action_{rnc.id}"): + col_a1, col_a2, col_a3, col_a4 = st.columns([1.6, 1, 1, 1]) + ac_tipo = col_a1.selectbox("Tipo da ação", ["Imediata/Contenção", "Corretiva", "Preventiva"], index=1, key=f"ac_tipo_{rnc.id}") + ac_desc = col_a1.text_input("Descrição da ação", key=f"ac_desc_{rnc.id}") + ac_resp = col_a2.text_input("Responsável", key=f"ac_resp_{rnc.id}") + ac_prazo = col_a3.date_input("Data de Conclusão (prazo)", value=None, format="DD/MM/YYYY", key=f"ac_prazo_{rnc.id}") + ac_enviar = col_a4.form_submit_button("Adicionar ação") + if ac_enviar: + if not ac_desc: + st.warning("Informe a descrição da ação.") + else: + try: + tipo_prefix = "[IMEDIATA/CONTENÇÃO]" if ac_tipo == "Imediata/Contenção" else ("[Preventiva]" if ac_tipo == "Preventiva" else "[Corretiva]") + new_ac = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"{tipo_prefix} {ac_desc.strip()}", + responsavel=(ac_resp or "").strip() or None, + prazo=datetime.combine(ac_prazo, datetime.min.time()) if isinstance(ac_prazo, date) else None, + status="Planejada" if not isinstance(ac_prazo, date) else "Concluída", + conclusao_em=datetime.combine(ac_prazo, datetime.min.time()) if isinstance(ac_prazo, date) else None + ) + db.add(new_ac) + db.commit() + _registrar_log(st.session_state.get("usuario"), f"Adicionou ação em {rnc.codigo}", "rnc_acao", new_ac.id) + st.success("Ação adicionada.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar ação: {e}") + + +def _mostrar_verificacao_eficacia(rnc: RNC, db, usuario: str, perfil: str): + st.markdown("### ✅ Verificação da Eficácia e Aprovação") + st.caption("Descreva como a eficácia será monitorada e registre o resultado das ações. No caso de **Não Eficaz**, a RNC será reaberta.") + with st.form(f"form_verif_{rnc.id}"): + plano = st.text_area("O que será feito para manter e acompanhar a eficácia das ações aplicadas?", height=120, key=f"verif_plano_{rnc.id}") + col_v1, col_v2, col_v3 = st.columns([1, 1, 1]) + resultado = col_v1.selectbox("Resultado das ações", ["(pendente)", "Eficaz", "Não Eficaz"], index=0, key=f"verif_res_{rnc.id}") + encerrar_agora = col_v2.checkbox("Encerrar RNC (se Eficaz)", value=False, key=f"verif_close_{rnc.id}") + resp_enc = col_v3.text_input("Responsável pelo encerramento (opcional)", value=usuario, key=f"verif_enc_{rnc.id}") + enviar = st.form_submit_button("Registrar verificação") + if enviar: + if not plano.strip() and resultado == "(pendente)": + st.warning("Registre o plano de verificação ou o resultado.") + return + try: + msg = f"[Verificação da Eficácia]\n{plano.strip() or '(sem plano)'}\n\nResultado: {resultado}" + cm = RNCComentario( + rnc_id=rnc.id, + autor=usuario, + mensagem=msg, + data=datetime.utcnow() + ) + db.add(cm) + + # Regras: se Não Eficaz → reabrir (Em Análise). Se Eficaz e encerrar → Encerrada. + if resultado == "Não Eficaz": + rnc.status = "Em Análise" + rnc.encerrada_em = None + db.add(rnc) + elif resultado == "Eficaz" and encerrar_agora and can_close_or_cancel(perfil): + rnc.status = "Encerrada" + rnc.encerrada_em = datetime.utcnow() + cm2 = RNCComentario( + rnc_id=rnc.id, + autor=resp_enc or usuario, + mensagem=f"[Encerramento] RNC encerrada por {resp_enc or usuario} em {datetime.utcnow().strftime('%d/%m/%Y %H:%M')}", + status_novo="Encerrada", + data=datetime.utcnow() + ) + db.add(cm2) + db.add(rnc) + db.commit() + + _registrar_log(usuario, f"Verificação de eficácia em {rnc.codigo} (resultado: {resultado})", "rnc", rnc.id) + st.success("Verificação registrada.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao registrar verificação: {e}") + + +def _mostrar_timeline(rnc: RNC, db, usuario: str, perfil: str): + st.markdown("### 🕒 Timeline / Andamento") + if rnc.comentarios: + for cm in sorted(rnc.comentarios, key=lambda c: c.data, reverse=True): + line = f"**{cm.data.strftime('%d/%m/%Y %H:%M')}** • {cm.autor}: {cm.mensagem}" + changes = [] + if cm.status_novo: + changes.append(f"Status → {cm.status_novo}") + if cm.responsavel_novo: + changes.append(f"Responsável → {cm.responsavel_novo}") + if cm.prazo_novo: + changes.append(f"Prazo → {cm.prazo_novo.strftime('%d/%m/%Y')}") + if changes: + line += " \n" + " • ".join(changes) + st.write(line) + else: + st.caption("Sem comentários ainda.") + + st.markdown("#### Comentário/Atualização") + with st.form(f"form_comment_{rnc.id}"): + msg = st.text_area("Comentário/atualização (ex.: progresso, decisões)", height=80, key=f"cm_msg_{rnc.id}") + col_u1, col_u2, col_u3, col_u4 = st.columns(4) + novo_status = col_u1.selectbox("Atualizar status", ["(sem mudança)"] + STATUS_OPCOES, index=0, key=f"cm_st_{rnc.id}") + novo_resp = col_u2.text_input("Atualizar responsável", key=f"cm_resp_{rnc.id}") + novo_prazo = col_u3.date_input("Atualizar prazo", value=None, format="DD/MM/YYYY", key=f"cm_prazo_{rnc.id}") + salvar_cm = col_u4.form_submit_button("Registrar") + + if salvar_cm: + if not msg.strip() and novo_status == "(sem mudança)" and not novo_resp and not novo_prazo: + st.warning("Registre um comentário ou alguma mudança.") + return + + aplicar_mudancas = can_edit(rnc, usuario, perfil) + status_desejado = None if novo_status == "(sem mudança)" else novo_status + + if status_desejado in ["Encerrada", "Cancelada"] and not can_close_or_cancel(perfil): + st.warning("Somente administradores podem **encerrar** ou **cancelar** RNC.") + aplicar_mudancas = False # não aplicar status proibido + + try: + cm = RNCComentario( + rnc_id=rnc.id, + autor=usuario, + mensagem=((msg or "").strip() or "(sem mensagem)"), + status_novo=status_desejado, + responsavel_novo=(novo_resp or "").strip() or None, + prazo_novo=datetime.combine(novo_prazo, datetime.min.time()) if isinstance(novo_prazo, date) else None, + data=datetime.utcnow() + ) + db.add(cm) + + # Aplica mudanças + if aplicar_mudancas: + if status_desejado: + rnc.status = status_desejado + if status_desejado == "Encerrada": + rnc.encerrada_em = datetime.utcnow() + if cm.responsavel_novo: + rnc.responsavel = cm.responsavel_novo + if cm.prazo_novo: + rnc.prazo = cm.prazo_novo + db.add(rnc) + db.commit() + + _registrar_log(usuario, f"Atualizou RNC {rnc.codigo}", "rnc", rnc.id) + + # Notificação (opcional) + if status_desejado in ["Verificação", "Encerrada"]: + emails = [] + if rnc.responsavel: + resp_email = _get_user_email(rnc.responsavel) + if resp_email: + emails.append(resp_email) + criador_email = _get_user_email(rnc.criado_por) + if criador_email: + emails.append(criador_email) + assunto = f"[IOI-RUN] RNC {rnc.codigo} — Status: {status_desejado}" + corpo = f"""A RNC {rnc.codigo} teve seu status atualizado para: {status_desejado}. + +Título: {rnc.titulo} +Responsável: {rnc.responsavel or '—'} +Prazo: {rnc.prazo.strftime('%d/%m/%Y') if rnc.prazo else '—'} +Atualizado por: {usuario} +Data: {datetime.utcnow().strftime('%d/%m/%Y %H:%M')} + +Comentário: +{(msg or '').strip() or '(sem mensagem)'}""" + _send_email_para_responsavel(assunto, corpo, emails) + + st.success("Atualização registradoa.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao registrar atualização: {e}") + + +def _mostrar_analise_aprovacao(rnc: RNC, db, usuario: str): + st.markdown("### 🧾 Análise Crítica e Aprovação") + st.caption("Registre os responsáveis pela análise crítica (QSMS) e aprovação (Diretoria).") + with st.form(f"form_aprov_{rnc.id}"): + st.markdown("**Análise Crítica (QSMS)**") + col_a1, col_a2 = st.columns([1, 1]) + analise_nome = col_a1.text_input("Nome (QSMS)", key=f"ap_nome_{rnc.id}") + analise_setor = col_a2.text_input("Setor (QSMS)", value="QSMS", key=f"ap_setor_{rnc.id}") + + st.markdown("**Aprovação (Diretoria)**") + col_b1, col_b2 = st.columns([1, 1]) + aprov_nome = col_b1.text_input("Nome (Diretoria)", key=f"ap_dir_nome_{rnc.id}") + aprov_setor = col_b2.text_input("Setor (Diretoria)", value="Diretor Executivo e de Operações", key=f"ap_dir_setor_{rnc.id}") + + enviar = st.form_submit_button("Registrar análise/aprovação") + if enviar: + try: + msg = f"[Análise Crítica / Aprovação]\nQSMS: {analise_nome or '—'} • Setor: {analise_setor or '—'}\nDiretoria: {aprov_nome or '—'} • Setor: {aprov_setor or '—'}" + cm = RNCComentario( + rnc_id=rnc.id, + autor=usuario, + mensagem=msg, + data=datetime.utcnow() + ) + db.add(cm) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao registrar análise/aprovação: {e}") + + +# =============================== +# Tema/Background específico do módulo RNC +# =============================== +def _apply_rnc_theme(): + """ + Aplica um tema visual específico para a página do módulo RNC + (background diferenciado, header destacado e sutis ajustes visuais). + """ + st.markdown( + """ + + """, + unsafe_allow_html=True + ) + + +# =============================== +# Página principal (container) +# =============================== +def _card_rnc_header(rnc: RNC): + col_a, col_b = st.columns([3, 1]) + with col_a: + st.markdown(f"**{rnc.codigo}** — {rnc.titulo or '—'}") + st.caption( + f"Origem: {rnc.origem or '—'} • " + f"Tipo: {rnc.tipo or '—'} • " + f"Severidade: {rnc.severidade or '—'} • " + f"Prioridade: {rnc.prioridade or '—'}" + ) + st.caption( + f"Responsável: {rnc.responsavel or '—'} • " + f"Prazo: {rnc.prazo.strftime('%d/%m/%Y') if rnc.prazo else '—'} • " + f"Abertura: {rnc.data_abertura.strftime('%d/%m/%Y %H:%M') if rnc.data_abertura else '—'}" + ) + with col_b: + col_b.metric("Status", rnc.status or "—") + + +def _blocos_rnc(rnc: RNC, db, usuario: str, perfil: str): + st.markdown("---") + # Conteúdo principal do card/expander + st.markdown("#### 📄 Cabeçalho / Descrição") + st.markdown(rnc.descricao or "—") + + st.markdown("---") + _mostrar_anexos(rnc, db) + + st.markdown("---") + _mostrar_plano_acao(rnc, db) + + st.markdown("---") + _mostrar_timeline(rnc, db, usuario, perfil) + + st.markdown("---") + _mostrar_verificacao_eficacia(rnc, db, usuario, perfil) + + st.markdown("---") + _mostrar_analise_aprovacao(rnc, db, usuario) + + +def pagina(): + """Tela principal do módulo RNC (cadastro + edição).""" + _apply_rnc_theme() + + st.header("RNC • Registro de Não Conformidades (FOR-SGQ-08 Rev 01)") + + usuario = st.session_state.get("usuario") or "desconhecido" + perfil = (st.session_state.get("perfil") or "user").lower() + + db = SessionLocal() + try: + # KPIs + _kpis_area(db, usuario, perfil) + st.divider() + + # Formulário de Nova RNC + _form_nova_rnc(db) + st.divider() + + # ✏️ Edição de RNC existente (sem listagem/export aqui) + _editar_rnc(db) + + # Se houver RNC carregada para edição, renderiza blocos auxiliares + if st.session_state.get("__rnc_edit_id__"): + rnc = db.query(RNC).filter(RNC.id == st.session_state["__rnc_edit_id__"]).first() + if rnc: + with st.expander(f"📂 Detalhes • {rnc.codigo} — {rnc.titulo or '—'}", expanded=False): + _card_rnc_header(rnc) + _blocos_rnc(rnc, db, usuario, perfil) + + except Exception as e: + st.error(f"Erro ao montar a página de RNC: {e}") + finally: + try: + db.close() + except Exception: + pass + + +# Alias opcional para compatibilidade com app.py +def view(): + """Alias de pagina() para compatibilidade.""" + return pagina() + + + + + + diff --git a/rnc_listagem.py b/rnc_listagem.py new file mode 100644 index 0000000000000000000000000000000000000000..533e7b822d8359e7323a2f099d1052fc5f363f3e --- /dev/null +++ b/rnc_listagem.py @@ -0,0 +1,613 @@ + +# rnc_listagem.py +# -*- coding: utf-8 -*- +""" +Módulo: RNC • Listagem e Filtros (somente consulta) +Recursos: +- Filtros de RNC +- Listagem com expanders (mesmo layout do módulo RNC principal) +- Exportação CSV, Excel, PDF +- Mantém as interações dentro dos expanders (anexos, timeline, plano de ação, verificação e aprovação) + OBS: Não contém formulário de criação nem KPIs. +""" + +import os +from datetime import datetime, date +from typing import Optional, List +from io import BytesIO + +import streamlit as st +import pandas as pd + +from banco import SessionLocal +from models import RNC, RNCComentario, RNCAcaoCorretiva, RNCAnexo + +# =============================== +# Configurações e constantes +# =============================== + +UPLOAD_DIR = os.getenv("RNC_UPLOAD_DIR", os.path.join("uploads", "rnc")) + +# Permissões (mesmas do módulo principal) +ALLOW_CREATOR_OR_RESP_TO_UPDATE = True +ONLY_ADMIN_CAN_CLOSE_OR_CANCEL = True + +STATUS_OPCOES = [ + "Aberta", "Em Análise", "Plano de Ação", + "Implementada", "Verificação", "Encerrada", "Cancelada" +] + +# =============================== +# Utilidades auxiliares +# =============================== +def _ensure_upload_dir(path: str): + try: + os.makedirs(path, exist_ok=True) + except Exception: + pass + +def _registrar_log(usuario: Optional[str], acao: str, tabela: str, registro_id: Optional[int] = None): + try: + from utils_auditoria import registrar_log + registrar_log(usuario=usuario, acao=acao, tabela=tabela, registro_id=registro_id) + except Exception: + pass + +def _send_email_para_responsavel(assunto: str, corpo: str, destinatarios: List[str]): + """Envia e-mail (opcional). Tenta utils_email ou utils_notificacao. Ignora se indisponível.""" + if not destinatarios: + return + try: + from utils_email import send_email + for to in destinatarios: + try: + send_email(to, assunto, corpo) + except Exception: + pass + return + except Exception: + pass + try: + from utils_notificacao import send_email + for to in destinatarios: + try: + send_email(to, assunto, corpo) + except Exception: + pass + return + except Exception: + pass + +def _get_user_email(login: Optional[str]) -> Optional[str]: + """Tenta obter e-mail do usuário em st.session_state (ajuste conforme sua infra).""" + if not login: + return None + return st.session_state.get("email") + +def _is_admin(perfil: str) -> bool: + return (perfil or "").lower() == "admin" + +def can_edit(rnc: RNC, usuario: Optional[str], perfil: str) -> bool: + """Pode editar campos (status/prazo/responsável)?""" + if _is_admin(perfil): + return True + if not ALLOW_CREATOR_OR_RESP_TO_UPDATE: + return False + if not usuario: + return False + return usuario == (rnc.criado_por or "") or usuario == (rnc.responsavel or "") + +def can_close_or_cancel(perfil: str) -> bool: + """Pode ENCERRAR ou CANCELAR?""" + if ONLY_ADMIN_CAN_CLOSE_OR_CANCEL: + return _is_admin(perfil) + return True + + +# =============================== +# Filtros / Export +# =============================== +def _filtros_listagem(db): + st.subheader("🔎 Filtros") + col0, col1, col2, col3 = st.columns([1, 1, 1, 1]) + codigo_filtro = col0.text_input("Código (ex.: RNC-2026-0001)") + status_sel = col1.multiselect("Status", STATUS_OPCOES, default=[]) + emitente_filtro = col2.text_input("Emitente (contém)") + busca_texto = col3.text_input("Buscar em título/descrição") + + col4, col5 = st.columns(2) + dt_ini = col4.date_input("Abertas a partir de", value=None, format="DD/MM/YYYY") + dt_fim = col5.date_input("Até", value=None, format="DD/MM/YYYY") + + q = db.query(RNC) + if codigo_filtro.strip(): + q = q.filter(RNC.codigo.ilike(f"%{codigo_filtro.strip()}%")) + if status_sel: + q = q.filter(RNC.status.in_(status_sel)) + if emitente_filtro.strip(): + q = q.filter(RNC.emitente.ilike(f"%{emitente_filtro.strip()}%")) + if busca_texto.strip(): + from sqlalchemy import or_ + txt = f"%{busca_texto.strip()}%" + q = q.filter(or_(RNC.titulo.ilike(txt), RNC.descricao.ilike(txt))) + if dt_ini: + q = q.filter(RNC.data_abertura >= datetime.combine(dt_ini, datetime.min.time())) + if dt_fim: + q = q.filter(RNC.data_abertura <= datetime.combine(dt_fim, datetime.max.time())) + + q = q.order_by(RNC.data_abertura.desc()) + return q.all() + +def _exportar(rncs: List[RNC]): + st.markdown("#### Exportação") + if not rncs: + st.caption("Nada para exportar.") + return + rows = [] + for r in rncs: + rows.append({ + "Código": r.codigo, + "Título": r.titulo, + "Status": r.status, + "Severidade": r.severidade or "", + "Prioridade": r.prioridade or "", + "Responsável": r.responsavel or "", + "Prazo": r.prazo.strftime("%d/%m/%Y") if r.prazo else "", + "Abertura": r.data_abertura.strftime("%d/%m/%Y %H:%M") if r.data_abertura else "", + "Encerrada em": r.encerrada_em.strftime("%d/%m/%Y %H:%M") if getattr(r, "encerrada_em", None) else "", + "Origem": r.origem or "", + "Área Solicitante": r.area_solicitante or "", + "Área Notificada": r.area_notificada or "", + "Criado por": r.criado_por or "", + "Cliente": r.cliente or "", + "Local": r.local or "", + "Metodologia": getattr(r, "metodologia", "") or "", + "Causa Raiz": r.causa_raiz or "" + }) + df = pd.DataFrame(rows) + csv = df.to_csv(index=False).encode("utf-8-sig") + st.download_button("⬇️ Exportar CSV", csv, file_name=f"rnc_export_{datetime.utcnow().strftime('%Y%m%d_%H%M')}.csv") + + # Excel + excel_file = f"rnc_export_{datetime.utcnow().strftime('%Y%m%d_%H%M')}.xlsx" + excel_buffer = BytesIO() + with pd.ExcelWriter(excel_buffer, engine="xlsxwriter") as writer: + df.to_excel(writer, index=False, sheet_name="RNCs") + st.download_button( + "⬇️ Exportar Excel", + excel_buffer.getvalue(), + file_name=excel_file, + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + + # PDF (simples) + try: + from reportlab.lib.pagesizes import letter + from reportlab.pdfgen import canvas + + pdf_buffer = BytesIO() + c = canvas.Canvas(pdf_buffer, pagesize=letter) + c.setFont("Helvetica", 10) + c.drawString(30, 750, "Relatório de RNCs") + y = 730 + for _, row in df.iterrows(): + text = f"{row['Código']} | {row.get('Emitente', '')} | {row['Status']} | {row['Origem']}" + c.drawString(30, y, text) + y -= 15 + if y < 50: + c.showPage() + y = 750 + c.save() + st.download_button( + "⬇️ Exportar PDF", + pdf_buffer.getvalue(), + file_name="rnc_export.pdf", + mime="application/pdf" + ) + except Exception: + st.warning("Biblioteca para PDF não disponível. Instale reportlab para habilitar.") + + +# =============================== +# Sub-blocos de exibição (iguais ao módulo principal) +# =============================== +def _mostrar_anexos(rnc: RNC, db): + st.markdown("**Anexos**") + if rnc.anexos: + for an in rnc.anexos: + st.caption(f"📎 {an.nome_arquivo} • {an.enviado_por or '—'} • {an.enviado_em.strftime('%d/%m/%Y %H:%M')}") + if os.path.exists(an.caminho): + try: + with open(an.caminho, "rb") as f: + st.download_button( + f"Baixar {an.nome_arquivo}", + f.read(), + file_name=an.nome_arquivo, + key=f"dl_{rnc.id}_{an.id}" + ) + except Exception: + st.caption("Arquivo indisponível.") + else: + st.caption("Sem anexos") + + # Upload incremental continua disponível (pois a listagem mantém as interações) + up_more = st.file_uploader( + f"Adicionar anexos — {rnc.codigo}", + accept_multiple_files=True, + key=f"up_{rnc.id}" + ) + if up_more: + dest_dir = os.path.join(UPLOAD_DIR, rnc.codigo) + _ensure_upload_dir(dest_dir) + try: + for up in up_more: + dest_path = os.path.join(dest_dir, up.name) + with open(dest_path, "wb") as f: + f.write(up.getbuffer()) + anexo = RNCAnexo( + rnc_id=rnc.id, + nome_arquivo=up.name, + caminho=dest_path, + conteudo_tipo=getattr(up, "type", None), + enviado_por=st.session_state.get("usuario"), + enviado_em=datetime.utcnow() + ) + db.add(anexo) + db.commit() + st.success("Anexos adicionados.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar anexos: {e}") + +def _mostrar_plano_acao(rnc: RNC, db): + st.markdown("### 🛠️ Plano de Ação — Separado por categorias") + + def _cat(descricao: str) -> str: + s = (descricao or "").strip() + if s.startswith("[IMEDIATA/CONTENÇÃO]"): + return "imediata" + elif s.startswith("[Preventiva]"): + return "preventiva" + elif s.startswith("[Corretiva]"): + return "corretiva" + else: + return "outras" + + acoes = rnc.acoes or [] + acoes_imediatas = [a for a in acoes if _cat(a.descricao) == "imediata"] + acoes_corretivas = [a for a in acoes if _cat(a.descricao) == "corretiva"] + acoes_preventivas = [a for a in acoes if _cat(a.descricao) == "preventiva"] + acoes_outras = [a for a in acoes if _cat(a.descricao) == "outras"] + + def _render_bloco(titulo: str, lista: List[RNCAcaoCorretiva]): + st.markdown(f"#### {titulo}") + if not lista: + st.caption("— Nenhuma ação cadastrada nesta categoria.") + return + lista = sorted(lista, key=lambda a: (a.status, a.prazo or datetime.max)) + for ac in lista: + line = f"- {ac.descricao}" + line += f" • Resp: {ac.responsavel or '—'}" + line += f" • Prazo: {ac.prazo.strftime('%d/%m/%Y') if ac.prazo else '—'}" + line += f" • Status: {ac.status}" + if ac.eficacia: + line += f" • Eficácia: {ac.eficacia}" + st.write(line) + + with st.expander(f"Atualizar ação • ID {ac.id}", expanded=False): + col_s1, col_s2, col_s3 = st.columns(3) + status_opts = ["Planejada", "Em execução", "Concluída", "Ineficaz"] + novo_status = col_s1.selectbox( + "Status", + status_opts, + index=status_opts.index(ac.status) if ac.status in status_opts else 0, + key=f"ac_st_{ac.id}" + ) + eff_opts = ["(não avaliada)", "Eficaz", "Parcial", "Ineficaz"] + nova_eficacia = col_s2.selectbox( + "Eficácia (resultado da ação)", + eff_opts, + index=eff_opts.index(ac.eficacia) if ac.eficacia in eff_opts else 0, + key=f"ac_eff_{ac.id}" + ) + concluir = col_s3.button("Salvar atualização", key=f"ac_sv_{ac.id}") + + if concluir: + try: + ac.status = novo_status + if novo_status == "Concluída": + ac.conclusao_em = datetime.utcnow() + ac.eficacia = None if nova_eficacia == "(não avaliada)" else nova_eficacia + db.add(ac) + db.commit() + _registrar_log( + st.session_state.get("usuario"), + f"Atualizou ação RNC {rnc.codigo} (ID ação {ac.id})", + "rnc_acao", + ac.id + ) + st.success("Ação atualizada.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao atualizar ação: {e}") + + _render_bloco("🚀 Ações Imediatas/Contenção", acoes_imediatas) + _render_bloco("🔧 Ações Corretivas", acoes_corretivas) + _render_bloco("🛡️ Ações Preventivas", acoes_preventivas) + _render_bloco("📁 Outras (sem prefixo)", acoes_outras) + +def _mostrar_verificacao_eficacia(rnc: RNC, db, usuario: str, perfil: str): + st.markdown("### ✅ Verificação da Eficácia e Aprovação") + st.caption("Descreva como a eficácia será monitorada e registre o resultado das ações. No caso de **Não Eficaz**, a RNC será reaberta.") + with st.form(f"form_verif_{rnc.id}"): + plano = st.text_area( + "O que será feito para manter e acompanhar a eficácia das ações aplicadas?", + height=120, + key=f"verif_plano_{rnc.id}" + ) + col_v1, col_v2, col_v3 = st.columns([1, 1, 1]) + resultado = col_v1.selectbox("Resultado das ações", ["(pendente)", "Eficaz", "Não Eficaz"], index=0, key=f"verif_res_{rnc.id}") + encerrar_agora = col_v2.checkbox("Encerrar RNC (se Eficaz)", value=False, key=f"verif_close_{rnc.id}") + resp_enc = col_v3.text_input("Responsável pelo encerramento (opcional)", value=usuario, key=f"verif_enc_{rnc.id}") + enviar = st.form_submit_button("Registrar verificação") + if enviar: + if not plano.strip() and resultado == "(pendente)": + st.warning("Registre o plano de verificação ou o resultado.") + return + try: + msg = f"[Verificação da Eficácia]\n{plano.strip() or '(sem plano)'}\n\nResultado: {resultado}" + cm = RNCComentario( + rnc_id=rnc.id, + autor=usuario, + mensagem=msg, + data=datetime.utcnow() + ) + db.add(cm) + + if resultado == "Não Eficaz": + rnc.status = "Em Análise" + rnc.encerrada_em = None + db.add(rnc) + elif resultado == "Eficaz" and encerrar_agora and can_close_or_cancel(perfil): + rnc.status = "Encerrada" + rnc.encerrada_em = datetime.utcnow() + cm2 = RNCComentario( + rnc_id=rnc.id, + autor=resp_enc or usuario, + mensagem=f"[Encerramento] RNC encerrada por {resp_enc or usuario} em {datetime.utcnow().strftime('%d/%m/%Y %H:%M')}", + status_novo="Encerrada", + data=datetime.utcnow() + ) + db.add(cm2) + db.add(rnc) + db.commit() + _registrar_log(usuario, f"Verificação de eficácia em {rnc.codigo} (resultado: {resultado})", "rnc", rnc.id) + st.success("Verificação registrada.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao registrar verificação: {e}") + +def _mostrar_timeline(rnc: RNC, db, usuario: str, perfil: str): + st.markdown("### 🕒 Timeline / Andamento") + if rnc.comentarios: + for cm in sorted(rnc.comentarios, key=lambda c: c.data, reverse=True): + line = f"**{cm.data.strftime('%d/%m/%Y %H:%M')}** • {cm.autor}: {cm.mensagem}" + changes = [] + if cm.status_novo: + changes.append(f"Status → {cm.status_novo}") + if cm.responsavel_novo: + changes.append(f"Responsável → {cm.responsavel_novo}") + if cm.prazo_novo: + changes.append(f"Prazo → {cm.prazo_novo.strftime('%d/%m/%Y')}") + if changes: + line += " \n" + " • ".join(changes) + st.write(line) + else: + st.caption("Sem comentários ainda.") + + st.markdown("#### Comentário/Atualização") + with st.form(f"form_comment_{rnc.id}"): + msg = st.text_area("Comentário/atualização (ex.: progresso, decisões)", height=80, key=f"cm_msg_{rnc.id}") + col_u1, col_u2, col_u3, col_u4 = st.columns(4) + novo_status = col_u1.selectbox("Atualizar status", ["(sem mudança)"] + STATUS_OPCOES, index=0, key=f"cm_st_{rnc.id}") + novo_resp = col_u2.text_input("Atualizar responsável", key=f"cm_resp_{rnc.id}") + novo_prazo = col_u3.date_input("Atualizar prazo", value=None, format="DD/MM/YYYY", key=f"cm_prazo_{rnc.id}") + salvar_cm = col_u4.form_submit_button("Registrar") + + if salvar_cm: + if not msg.strip() and novo_status == "(sem mudança)" and not novo_resp and not novo_prazo: + st.warning("Registre um comentário ou alguma mudança.") + return + + aplicar_mudancas = can_edit(rnc, usuario, perfil) + status_desejado = None if novo_status == "(sem mudança)" else novo_status + + if status_desejado in ["Encerrada", "Cancelada"] and not can_close_or_cancel(perfil): + st.warning("Somente administradores podem **encerrar** ou **cancelar** RNC.") + aplicar_mudancas = False + + try: + cm = RNCComentario( + rnc_id=rnc.id, + autor=usuario, + mensagem=((msg or "").strip() or "(sem mensagem)"), + status_novo=status_desejado, + responsavel_novo=(novo_resp or "").strip() or None, + prazo_novo=datetime.combine(novo_prazo, datetime.min.time()) if isinstance(novo_prazo, date) else None, + data=datetime.utcnow() + ) + db.add(cm) + + if aplicar_mudancas: + if status_desejado: + rnc.status = status_desejado + if status_desejado == "Encerrada": + rnc.encerrada_em = datetime.utcnow() + if cm.responsavel_novo: + rnc.responsavel = cm.responsavel_novo + if cm.prazo_novo: + rnc.prazo = cm.prazo_novo + db.add(rnc) + db.commit() + + _registrar_log(usuario, f"Atualizou RNC {rnc.codigo}", "rnc", rnc.id) + + if status_desejado in ["Verificação", "Encerrada"]: + emails = [] + if rnc.responsavel: + resp_email = _get_user_email(rnc.responsavel) + if resp_email: + emails.append(resp_email) + criador_email = _get_user_email(rnc.criado_por) + if criador_email: + emails.append(criador_email) + assunto = f"[IOI-RUN] RNC {rnc.codigo} — Status: {status_desejado}" + corpo = f"""A RNC {rnc.codigo} teve seu status atualizado para: {status_desejado}. + +Título: {rnc.titulo} +Responsável: {rnc.responsavel or '—'} +Prazo: {rnc.prazo.strftime('%d/%m/%Y') if rnc.prazo else '—'} +Atualizado por: {usuario} +Data: {datetime.utcnow().strftime('%d/%m/%Y %H:%M')} + +Comentário: +{(msg or '').strip() or '(sem mensagem)'}""" + _send_email_para_responsavel(assunto, corpo, emails) + + st.success("Atualização registrada.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao registrar atualização: {e}") + +def _mostrar_analise_aprovacao(rnc: RNC, db, usuario: str): + st.markdown("### 🧾 Análise Crítica e Aprovação") + st.caption("Registre os responsáveis pela análise crítica (QSMS) e aprovação (Diretoria).") + with st.form(f"form_aprov_{rnc.id}"): + st.markdown("**Análise Crítica (QSMS)**") + col_a1, col_a2 = st.columns([1, 1]) + analise_nome = col_a1.text_input("Nome (QSMS)", key=f"ap_nome_{rnc.id}") + analise_setor = col_a2.text_input("Setor (QSMS)", value="QSMS", key=f"ap_setor_{rnc.id}") + + st.markdown("**Aprovação (Diretoria)**") + col_b1, col_b2 = st.columns([1, 1]) + aprov_nome = col_b1.text_input("Nome (Diretoria)", key=f"ap_dir_nome_{rnc.id}") + aprov_setor = col_b2.text_input("Setor (Diretoria)", value="Diretor Executivo e de Operações", key=f"ap_dir_setor_{rnc.id}") + + enviar = st.form_submit_button("Registrar análise/aprovação") + if enviar: + try: + msg = f"[Análise Crítica / Aprovação]\nQSMS: {analise_nome or '—'} • Setor: {analise_setor or '—'}\nDiretoria: {aprov_nome or '—'} • Setor: {aprov_setor or '—'}" + cm = RNCComentario( + rnc_id=rnc.id, + autor=usuario, + mensagem=msg, + data=datetime.utcnow() + ) + db.add(cm) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao registrar análise/aprovação: {e}") + +def _card_rnc_header(rnc: RNC): + col_a, col_b = st.columns([3, 1]) + with col_a: + st.markdown(f"**{rnc.codigo}** — {rnc.titulo or '—'}") + st.caption( + f"Origem: {rnc.origem or '—'} • " + f"Tipo: {rnc.tipo or '—'} • " + f"Severidade: {rnc.severidade or '—'} • " + f"Prioridade: {rnc.prioridade or '—'}" + ) + st.caption( + f"Responsável: {rnc.responsavel or '—'} • " + f"Prazo: {rnc.prazo.strftime('%d/%m/%Y') if rnc.prazo else '—'} • " + f"Abertura: {rnc.data_abertura.strftime('%d/%m/%Y %H:%M') if rnc.data_abertura else '—'}" + ) + with col_b: + col_b.metric("Status", rnc.status or "—") + +def _blocos_rnc(rnc: RNC, db, usuario: str, perfil: str): + st.markdown("---") + st.markdown("#### 📄 Cabeçalho / Descrição") + st.markdown(rnc.descricao or "—") + + st.markdown("---") + _mostrar_anexos(rnc, db) + + st.markdown("---") + _mostrar_plano_acao(rnc, db) + + st.markdown("---") + _mostrar_timeline(rnc, db, usuario, perfil) + + st.markdown("---") + _mostrar_verificacao_eficacia(rnc, db, usuario, perfil) + + st.markdown("---") + _mostrar_analise_aprovacao(rnc, db, usuario) + + +# =============================== +# Tema específico (opcional, leve) +# =============================== +def _apply_rnc_list_theme(): + st.markdown( + """ + + """, + unsafe_allow_html=True + ) + + +# =============================== +# Página principal da LISTAGEM +# =============================== +def pagina(): + """Tela dedicada à LISTAGEM de RNC (somente consulta).""" + _apply_rnc_list_theme() + + st.header("RNC • Listagem e Filtros") + + usuario = st.session_state.get("usuario") or "desconhecido" + perfil = (st.session_state.get("perfil") or "user").lower() + + db = SessionLocal() + try: + # Filtros + Listagem + Exportação + rncs = _filtros_listagem(db) + _exportar(rncs) + + st.markdown("---") + if not rncs: + st.info("Nenhuma RNC encontrada com os filtros aplicados.") + return + + for rnc in rncs: + with st.expander(f"{rnc.codigo} — {rnc.titulo or '—'} • {rnc.status or '—'}", expanded=False): + _card_rnc_header(rnc) + _blocos_rnc(rnc, db, usuario, perfil) + + except Exception as e: + st.error(f"Erro ao montar a listagem de RNC: {e}") + finally: + try: + db.close() + except Exception: + pass + + +# Alias opcional +def view(): + """Alias de pagina() para compatibilidade.""" + return pagina() diff --git a/rnc_relatorio.py b/rnc_relatorio.py new file mode 100644 index 0000000000000000000000000000000000000000..d6694df36bab7e6c5e6f3281a09f13290f6a67e3 --- /dev/null +++ b/rnc_relatorio.py @@ -0,0 +1,625 @@ + +# rnc_relatorio.py +# -*- coding: utf-8 -*- +""" +Módulo: RNC • Relatórios e Indicadores +Objetivo: +- Painel analítico completo das RNCs com filtros avançados +- Cards executivos e cards por Áreas +- Indicadores de desempenho (MTTC, SLA, backlog, aging etc.) +- Gráficos temporais (abertas x encerradas, backlog), distribuição por status, severidade, origem, área, Pareto por cliente, heatmaps etc. +- Exportação de base filtrada (CSV/XLSX) + +Requisitos: +- models.RNC: campos usados (cobertos no módulo rnc.py) + codigo, status, severidade, prioridade, tipo, origem, area_solicitante, area_notificada, + responsavel, cliente, local, emitente, data_abertura (datetime), encerrada_em (datetime|None), prazo (datetime|None) +""" + +import os +from datetime import datetime, date +from typing import List, Optional, Dict +from io import BytesIO + +import numpy as np +import pandas as pd +import matplotlib.pyplot as plt +import streamlit as st + +from banco import SessionLocal +from models import RNC + + +# =============================== +# Config / Aparência +# =============================== +def _apply_rel_theme(): + st.markdown( + """ + + """, + unsafe_allow_html=True + ) + + +# =============================== +# Utilitários de DataFrame +# =============================== +def _to_df(rncs: List[RNC]) -> pd.DataFrame: + """Converte lista de RNC em DataFrame normalizado para indicadores.""" + rows = [] + now = datetime.utcnow() + for r in rncs: + data_abe = r.data_abertura + enc = r.encerrada_em + prazo = r.prazo + lead = (enc - data_abe).days if (enc and data_abe) else None + aberta = r.status not in ["Encerrada", "Cancelada"] + dias_aberta = (now - data_abe).days if (aberta and data_abe) else (lead if lead is not None else None) + atrasada_open = (aberta and prazo is not None and prazo < now) + on_time = (enc is not None and prazo is not None and enc <= prazo) + + rows.append({ + "id": r.id, + "codigo": r.codigo, + "status": r.status, + "severidade": r.severidade or "", + "prioridade": r.prioridade or "", + "tipo": r.tipo or "", + "origem": r.origem or "", + "area_solicitante": r.area_solicitante or "", + "area_notificada": r.area_notificada or "", + "responsavel": r.responsavel or "", + "cliente": r.cliente or "", + "local": r.local or "", + "emitente": getattr(r, "emitente", "") or "", + "data_abertura": data_abe, + "encerrada_em": enc, + "prazo": prazo, + "aberta": aberta, + "dias_aberta": dias_aberta, + "lead_time_dias": lead, + "on_time": on_time, + "atrasada_open": atrasada_open, + # Buckets de aging + "aging_bucket": _aging_bucket(dias_aberta), + # Chaves temporais + "ano_mes": data_abe.strftime("%Y-%m") if data_abe else "", + "ano": data_abe.year if data_abe else None, + "mes": data_abe.month if data_abe else None, + "iso_semana": data_abe.isocalendar()[1] if data_abe else None, + "trimestre": ((data_abe.month - 1) // 3 + 1) if data_abe else None + }) + df = pd.DataFrame(rows) + # Tipos + for c in ["data_abertura", "encerrada_em", "prazo"]: + if c in df.columns: + df[c] = pd.to_datetime(df[c]) + return df + + +def _aging_bucket(dias: Optional[int]) -> str: + if dias is None: + return "—" + if dias <= 7: return "0–7d" + if dias <= 15: return "8–15d" + if dias <= 30: return "16–30d" + if dias <= 60: return "31–60d" + if dias <= 90: return "61–90d" + return "90+d" + + +def _apply_filters(df: pd.DataFrame, filtros: Dict) -> pd.DataFrame: + f = df.copy() + if filtros["dt_ini"]: + f = f[f["data_abertura"] >= pd.to_datetime(filtros["dt_ini"])] + if filtros["dt_fim"]: + f = f[f["data_abertura"] <= pd.to_datetime(filtros["dt_fim"]) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)] + for col in ["status", "severidade", "prioridade", "tipo", "origem", "area_solicitante", "area_notificada", "responsavel"]: + sel = filtros.get(col) + if sel: + f = f[f[col].isin(sel)] + # Textos “contém” + for txt_col in ["cliente", "emitente", "local", "codigo"]: + val = (filtros.get(f"c_{txt_col}") or "").strip() + if val: + f = f[f[txt_col].str.contains(val, case=False, na=False)] + return f + + +def _group_time(df: pd.DataFrame, gran: str) -> pd.DataFrame: + """Agrupa por período temporal (Mês, Semana ISO, Trimestre).""" + gdf = df.copy() + if gran == "Mês": + key = "ano_mes" + elif gran == "Semana (ISO)": + key = "iso_semana" + elif gran == "Trimestre": + key = "trimestre" + else: + key = "ano_mes" + # Abertas por período (data de abertura) + opened = gdf.groupby(key, dropna=False)["id"].count().rename("abertas") + # Encerradas por período (pela data de encerramento) + closed = ( + gdf.dropna(subset=["encerrada_em"]) + .assign(**{ + key: lambda d: ( + d["encerrada_em"].dt.strftime("%Y-%m") if gran == "Mês" else + (d["encerrada_em"].dt.isocalendar().week if gran == "Semana (ISO)" else + ((d["encerrada_em"].dt.month - 1)//3 + 1)) + ) + }) + .groupby(key)["id"].count().rename("encerradas") + ) + out = pd.concat([opened, closed], axis=1).fillna(0).astype(int).reset_index().rename(columns={key: "periodo"}) + out = out.sort_values("periodo") + # Backlog: acumulado abertas - encerradas + out["backlog"] = (out["abertas"].cumsum() - out["encerradas"].cumsum()) + return out + + +# =============================== +# Filtros UI +# =============================== +def _filtros_ui(db) -> Dict: + st.subheader("🔎 Filtros") + col0, col1, col2, col3 = st.columns(4) + dt_ini = col0.date_input("De (data de abertura)", value=None, format="DD/MM/YYYY") + dt_fim = col1.date_input("Até (data de abertura)", value=None, format="DD/MM/YYYY") + gran = col2.selectbox("Granularidade temporal", ["Mês", "Semana (ISO)", "Trimestre"], index=0) + area_eixo = col3.selectbox("Cards por área", ["area_notificada", "area_solicitante"], index=0, + help="Qual campo usar para os cards por área") + + # Carregar opções (usando top values para não poluir combos) + # Pegamos 200 mais recentes para derivar valores comuns — ajuste se preferir outra estratégia + exemplos = db.query(RNC).order_by(RNC.data_abertura.desc()).limit(200).all() + df_ex = _to_df(exemplos) + + def opts(col): # valores únicos não vazios + vals = sorted([v for v in df_ex[col].dropna().unique().tolist() if (v or "").strip() != ""]) + return vals + + col4, col5, col6, col7 = st.columns(4) + status = col4.multiselect("Status", sorted(df_ex["status"].unique().tolist()), default=[]) + severidade = col5.multiselect("Severidade", opts("severidade"), default=[]) + prioridade = col6.multiselect("Prioridade", opts("prioridade"), default=[]) + tipo = col7.multiselect("Tipo", opts("tipo"), default=[]) + + col8, col9, col10, col11 = st.columns(4) + origem = col8.multiselect("Origem", opts("origem"), default=[]) + area_solicitante = col9.multiselect("Área Solicitante", opts("area_solicitante"), default=[]) + area_notificada = col10.multiselect("Área Notificada", opts("area_notificada"), default=[]) + responsavel = col11.multiselect("Responsável", opts("responsavel"), default=[]) + + col12, col13, col14, col15 = st.columns(4) + c_cliente = col12.text_input("Cliente (contém)") + c_emitente = col13.text_input("Emitente (contém)") + c_local = col14.text_input("Local (contém)") + c_codigo = col15.text_input("Código (contém)") + + return dict( + dt_ini=dt_ini, dt_fim=dt_fim, gran=gran, area_eixo=area_eixo, + status=status, severidade=severidade, prioridade=prioridade, tipo=tipo, + origem=origem, area_solicitante=area_solicitante, area_notificada=area_notificada, + responsavel=responsavel, + c_cliente=c_cliente, c_emitente=c_emitente, c_local=c_local, c_codigo=c_codigo + ) + + +# =============================== +# Cards Executivos +# =============================== +def _cards_executivo(df: pd.DataFrame): + st.subheader("📊 Visão Geral (Executivo)") + now = datetime.utcnow() + abertas = df["aberta"].sum() + encerradas = (df["status"] == "Encerrada").sum() + canceladas = (df["status"] == "Cancelada").sum() + em_analise = (df["status"] == "Em Análise").sum() + plano = (df["status"] == "Plano de Ação").sum() + implementada = (df["status"] == "Implementada").sum() + verificacao = (df["status"] == "Verificação").sum() + + # SLA (somente encerradas com prazo registrado) + df_done = df[(df["status"] == "Encerrada") & df["prazo"].notna() & df["encerrada_em"].notna()] + sla_rate = (df_done["on_time"].mean() * 100) if not df_done.empty else np.nan + lead_media = df[df["lead_time_dias"].notna()]["lead_time_dias"].mean() + lead_mediana = df[df["lead_time_dias"].notna()]["lead_time_dias"].median() + + # Atrasadas abertas + atrasadas_abertas = df["atrasada_open"].sum() + + col1, col2, col3, col4 = st.columns(4) + col1.metric("Abertas (WIP)", int(abertas)) + col2.metric("Encerradas", int(encerradas)) + col3.metric("Atrasadas (abertas)", int(atrasadas_abertas)) + col4.metric("SLA (encerradas no prazo)", f"{sla_rate:.1f}%" if sla_rate == sla_rate else "—") + + col5, col6, col7, col8 = st.columns(4) + col5.metric("Lead time médio (dias)", f"{lead_media:.1f}" if lead_media == lead_media else "—") + col6.metric("Lead time mediano (dias)", f"{lead_mediana:.1f}" if lead_mediana == lead_mediana else "—") + col7.metric("Em Análise", int(em_analise)) + col8.metric("Plano de Ação", int(plano)) + + col9, col10, col11, _ = st.columns(4) + col9.metric("Implementada", int(implementada)) + col10.metric("Verificação", int(verificacao)) + col11.metric("Canceladas", int(canceladas)) + + +# =============================== +# Cards por Áreas +# =============================== +def _cards_por_area(df: pd.DataFrame, area_col: str): + st.subheader(f"🏷️ Cards por Área ({'Notificada' if area_col=='area_notificada' else 'Solicitante'})") + if df.empty: + st.caption("Sem dados.") + return + + # Top áreas por volume total + agg = ( + df.groupby(area_col)["id"].count() + .sort_values(ascending=False) + .rename("total") + .to_frame() + ) + agg["abertas"] = df[df["aberta"]].groupby(area_col)["id"].count() + agg["encerradas"] = df[df["status"] == "Encerrada"].groupby(area_col)["id"].count() + agg["atrasadas_abertas"] = df[df["atrasada_open"]].groupby(area_col)["id"].count() + agg = agg.fillna(0).astype(int).reset_index() + + top_n = st.slider("Quantidade de áreas (cards)", min_value=3, max_value=30, value=8, step=1) + show = agg.head(top_n) + + cols = st.columns(min(top_n, 8)) + for i, (_, row) in enumerate(show.iterrows()): + if i > 0 and i % 8 == 0: + cols = st.columns(min(top_n - i, 8)) + idx = i % 8 + with cols[idx]: + st.markdown( + f""" +
+
{row[area_col] or '—'}
+
{int(row['total'])} RNCs
+ Abertas: {int(row['abertas'])} + Encerradas: {int(row['encerradas'])} + Atrasadas: {int(row['atrasadas_abertas'])} +
+ """, + unsafe_allow_html=True + ) + + +# =============================== +# Gráficos +# =============================== +def _chart_abertas_encerradas(df: pd.DataFrame, gran: str): + st.markdown("#### 📈 Abertas x Encerradas x Backlog (por período)") + if df.empty: + st.caption("Sem dados para o período/filtros.") + return + serie = _group_time(df, gran) + if serie.empty: + st.caption("Sem dados agregados.") + return + + fig, ax1 = plt.subplots(figsize=(9, 4)) + ax1.plot(serie["periodo"], serie["abertas"], label="Abertas", color="#38bdf8", marker="o") + ax1.plot(serie["periodo"], serie["encerradas"], label="Encerradas", color="#34d399", marker="o") + ax1.set_xlabel("Período") + ax1.set_ylabel("Quantidade") + ax1.tick_params(axis='x', rotation=45) + ax1.legend(loc="upper left") + + ax2 = ax1.twinx() + ax2.plot(serie["periodo"], serie["backlog"], label="Backlog", color="#f59e0b", linestyle="--", marker="s") + ax2.set_ylabel("Backlog") + ax2.legend(loc="upper right") + + st.pyplot(fig) + plt.close(fig) + + +def _chart_status(df: pd.DataFrame): + st.markdown("#### 🧩 Distribuição por Status") + if df.empty: + st.caption("Sem dados.") + return + dist = df.groupby("status")["id"].count().sort_values(ascending=False) + fig, ax = plt.subplots(figsize=(7, 3.8)) + dist.plot(kind="bar", color="#93c5fd", ax=ax) + ax.set_ylabel("Qtd") + ax.set_xlabel("Status") + st.pyplot(fig) + plt.close(fig) + + +def _chart_severidade_prioridade(df: pd.DataFrame): + st.markdown("#### ⚠️ Severidade e Prioridade") + if df.empty: + st.caption("Sem dados.") + return + col1, col2 = st.columns(2) + with col1: + sev = df.groupby("severidade")["id"].count().sort_values(ascending=False) + fig1, ax1 = plt.subplots(figsize=(6, 3.2)) + sev.plot(kind="bar", color="#fca5a5", ax=ax1) + ax1.set_xlabel("Severidade") + ax1.set_ylabel("Qtd") + st.pyplot(fig1) + plt.close(fig1) + with col2: + pri = df.groupby("prioridade")["id"].count().sort_values(ascending=False) + fig2, ax2 = plt.subplots(figsize=(6, 3.2)) + pri.plot(kind="bar", color="#fdba74", ax=ax2) + ax2.set_xlabel("Prioridade") + ax2.set_ylabel("Qtd") + st.pyplot(fig2) + plt.close(fig2) + + +def _chart_pareto_cliente(df: pd.DataFrame): + st.markdown("#### 📊 Pareto por Cliente (Top 15)") + top = ( + df.groupby("cliente")["id"].count() + .sort_values(ascending=False) + .head(15) + .rename("qtd") + ) + if top.empty: + st.caption("Sem dados de clientes.") + return + cum = (top.cumsum() / top.sum()) * 100 + fig, ax1 = plt.subplots(figsize=(9, 4)) + top.plot(kind="bar", color="#a78bfa", ax=ax1) + ax1.set_ylabel("Qtd") + ax1.set_xlabel("Cliente") + ax2 = ax1.twinx() + ax2.plot(top.index, cum.values, color="#fbbf24", marker="o", linewidth=2) + ax2.set_ylabel("% acumulado") + ax1.tick_params(axis='x', rotation=45) + st.pyplot(fig) + plt.close(fig) + + +def _chart_heatmap_area_sev(df: pd.DataFrame, area_col: str): + st.markdown(f"#### 🌡️ Heatmap: {area_col.replace('_', ' ').title()} x Severidade") + if df.empty: + st.caption("Sem dados.") + return + pivot = pd.pivot_table(df, values="id", index=area_col, columns="severidade", aggfunc="count", fill_value=0) + if pivot.empty: + st.caption("Sem dados para heatmap.") + return + + fig, ax = plt.subplots(figsize=(8, 5)) + im = ax.imshow(pivot.values, cmap="Blues") + ax.set_xticks(range(pivot.shape[1])) + ax.set_xticklabels(pivot.columns, rotation=0) + ax.set_yticks(range(pivot.shape[0])) + ax.set_yticklabels([x if x else "—" for x in pivot.index]) + for i in range(pivot.shape[0]): + for j in range(pivot.shape[1]): + ax.text(j, i, int(pivot.values[i, j]), ha="center", va="center", color="black") + ax.set_xlabel("Severidade") + ax.set_ylabel(area_col.replace("_", " ").title()) + st.pyplot(fig) + plt.close(fig) + + +def _chart_aging(df: pd.DataFrame): + st.markdown("#### ⏳ Aging (Abertas)") + ab = df[df["aberta"]] + if ab.empty: + st.caption("Sem RNCs abertas no filtro.") + return + dist = ab.groupby("aging_bucket")["id"].count().reindex(["0–7d", "8–15d", "16–30d", "31–60d", "61–90d", "90+d"], fill_value=0) + fig, ax = plt.subplots(figsize=(7, 3.5)) + dist.plot(kind="bar", color="#60a5fa", ax=ax) + ax.set_xlabel("Faixa de dias aberta") + ax.set_ylabel("Qtd") + st.pyplot(fig) + plt.close(fig) + + +def _chart_sla_area(df: pd.DataFrame, area_col: str): + st.markdown("#### 🟢 SLA por Área (Encerradas com prazo)") + fin = df[(df["status"] == "Encerrada") & df["prazo"].notna() & df["encerrada_em"].notna()] + if fin.empty: + st.caption("Sem RNCs encerradas com prazo para calcular SLA.") + return + sla = fin.groupby(area_col)["on_time"].mean().sort_values(ascending=False) * 100 + fig, ax = plt.subplots(figsize=(8, 4)) + sla.plot(kind="bar", color="#34d399", ax=ax) + ax.set_ylabel("% no prazo") + ax.set_xlabel(area_col.replace("_", " ").title()) + st.pyplot(fig) + plt.close(fig) + + +def _chart_leadtime_area(df: pd.DataFrame, area_col: str): + st.markdown("#### 🕐 Lead Time médio por Área (dias)") + fin = df[df["lead_time_dias"].notna()] + if fin.empty: + st.caption("Sem RNCs com lead time para exibir.") + return + lt = fin.groupby(area_col)["lead_time_dias"].mean().sort_values(ascending=True) + fig, ax = plt.subplots(figsize=(8, 4)) + lt.plot(kind="barh", color="#fbbf24", ax=ax) + ax.set_xlabel("Dias") + ax.set_ylabel(area_col.replace("_", " ").title()) + st.pyplot(fig) + plt.close(fig) + + +# =============================== +# Indicadores e Tabelas +# =============================== +def _indicadores_avancados(df: pd.DataFrame): + st.subheader("📐 Indicadores Avançados") + + # MTTC (Mean Time To Close) + fin = df[df["lead_time_dias"].notna()] + mttc = fin["lead_time_dias"].mean() if not fin.empty else np.nan + + # % encerradas no prazo (SLA) + fin_sla = df[(df["status"] == "Encerrada") & df["prazo"].notna() & df["encerrada_em"].notna()] + sla = fin_sla["on_time"].mean() * 100 if not fin_sla.empty else np.nan + + # Overdue rate (abertas com prazo vencido) + open_total = df["aberta"].sum() + overdue_rate = (df["atrasada_open"].sum() / open_total * 100) if open_total > 0 else np.nan + + # WIP (abertas) + wip = int(df["aberta"].sum()) + + # Backlog corrente + backlog_corrente = int(df["aberta"].sum() + (df["status"] == "Plano de Ação").sum()) + + col1, col2, col3, col4, col5 = st.columns(5) + col1.metric("MTTC (dias)", f"{mttc:.1f}" if mttc == mttc else "—") + col2.metric("SLA (encerradas no prazo)", f"{sla:.1f}%" if sla == sla else "—") + col3.metric("Overdue rate (abertas)", f"{overdue_rate:.1f}%" if overdue_rate == overdue_rate else "—") + col4.metric("WIP", wip) + col5.metric("Backlog corrente", backlog_corrente) + + with st.expander("Quebra por mês (Tabela)"): + dfm = df.copy() + dfm["mes_ano"] = dfm["data_abertura"].dt.to_period("M").astype(str) + tb = dfm.groupby("mes_ano").agg( + abertas=("id", "count"), + encerradas=("status", lambda s: (s == "Encerrada").sum()), + atrasadas_abertas=("atrasada_open", "sum"), + sla_rate=("on_time", "mean"), + leadtime_medio=("lead_time_dias", "mean"), + ).reset_index() + tb["sla_rate"] = (tb["sla_rate"] * 100).round(1) + tb["leadtime_medio"] = tb["leadtime_medio"].round(1) + st.dataframe(tb, use_container_width=True) + + +def _exportar_base(df: pd.DataFrame): + st.subheader("⬇️ Exportar base filtrada") + if df.empty: + st.caption("Nada para exportar.") + return + # CSV + csv = df.to_csv(index=False).encode("utf-8-sig") + st.download_button( + "Exportar CSV", + csv, + file_name=f"rnc_relatorio_{datetime.utcnow().strftime('%Y%m%d_%H%M')}.csv" + ) + # XLSX + xbuf = BytesIO() + with pd.ExcelWriter(xbuf, engine="xlsxwriter") as writer: + df.to_excel(writer, index=False, sheet_name="RNCs") + st.download_button( + "Exportar Excel", + xbuf.getvalue(), + file_name=f"rnc_relatorio_{datetime.utcnow().strftime('%Y%m%d_%H%M')}.xlsx", + mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ) + + +# =============================== +# Página Principal +# =============================== +def pagina(): + """Tela analítica de Relatórios de RNC.""" + _apply_rel_theme() + st.header("RNC • Relatórios e Indicadores") + + db = SessionLocal() + try: + # Filtros + filtros = _filtros_ui(db) + + # Carrega tudo (para filtros sempre completos na base final) + rncs_all = db.query(RNC).all() + df_all = _to_df(rncs_all) + + # Aplica filtros + dff = _apply_filters(df_all, filtros) + + # Cards executivos + st.markdown("---") + _cards_executivo(dff) + + # Cards por áreas (alternável) + st.markdown("---") + _cards_por_area(dff, filtros["area_eixo"]) + + # Gráficos principais + st.markdown("---") + _chart_abertas_encerradas(dff, filtros["gran"]) + + colg1, colg2 = st.columns(2) + with colg1: + _chart_status(dff) + with colg2: + _chart_severidade_prioridade(dff) + + st.markdown("---") + _chart_pareto_cliente(dff) + + st.markdown("---") + _chart_heatmap_area_sev(dff, filtros["area_eixo"]) + + st.markdown("---") + colz1, colz2 = st.columns(2) + with colz1: + _chart_aging(dff) + with colz2: + _chart_sla_area(dff, filtros["area_eixo"]) + + st.markdown("---") + _chart_leadtime_area(dff, filtros["area_eixo"]) + + # Indicadores avançados (com tabela mensal) + st.markdown("---") + _indicadores_avancados(dff) + + # Dataset e exportação + st.markdown("---") + with st.expander("🔬 Ver base filtrada (tabela)"): + # Colunas prioritárias primeiro + cols = [ + "codigo", "status", "severidade", "prioridade", "tipo", "origem", + "area_solicitante", "area_notificada", "responsavel", + "cliente", "local", "emitente", + "data_abertura", "prazo", "encerrada_em", + "aberta", "atrasada_open", "on_time", "dias_aberta", "lead_time_dias" + ] + cols = [c for c in cols if c in dff.columns] + st.dataframe(dff[cols].sort_values("data_abertura", ascending=False), use_container_width=True) + + _exportar_base(dff) + + except Exception as e: + st.error(f"Erro ao montar relatório de RNC: {e}") + finally: + try: + db.close() + except Exception: + pass + + +# Alias opcional +def view(): + return pagina() diff --git a/run_app.bat b/run_app.bat new file mode 100644 index 0000000000000000000000000000000000000000..e15c7a8ccfffbb4d53525c077932e550322534be --- /dev/null +++ b/run_app.bat @@ -0,0 +1,5 @@ +@echo off +cd /d "%~dp0" +call venv\Scripts\activate +streamlit run app.py +pause diff --git a/scan_keys.py b/scan_keys.py new file mode 100644 index 0000000000000000000000000000000000000000..ecabde2674fe66a544459bfe20a8e137ee4f060e --- /dev/null +++ b/scan_keys.py @@ -0,0 +1,47 @@ + +# scan_keys.py +import os +import re +from collections import defaultdict + +PATTERNS = { + "form": re.compile(r'st\.form\(\s*\'"[\'"]\s*\)'), + "download_button": re.compile(r'st\.download_button\([^)]*key\s*=\s*\'"[\'"]'), + "button": re.compile(r'st\.button\([^)]*key\s*=\s*\'"[\'"]'), + "selectbox": re.compile(r'st\.selectbox\([^)]*key\s*=\s*\'"[\'"]'), + "text_input": re.compile(r'st\.text_input\([^)]*key\s*=\s*\'"[\'"]'), + "text_area": re.compile(r'st\.text_area\([^)]*key\s*=\s*\'"[\'"]'), +} + +def scan_file(path): + hits = defaultdict(list) + with open(path, 'r', encoding='utf-8', errors='ignore') as f: + for i, line in enumerate(f, 1): + for name, pat in PATTERNS.items(): + for m in pat.finditer(line): + hits[name].append((m.group(1), i)) + return hits + +def main(root="."): + dup_report = defaultdict(lambda: defaultdict(list)) + for dirpath, _, filenames in os.walk(root): + for fn in filenames: + if fn.endswith(".py"): + full = os.path.join(dirpath, fn) + hits = scan_file(full) + for kind, pairs in hits.items(): + seen = defaultdict(list) + for key, ln in pairs: + seen[key].append(ln) + for key, lines in seen.items(): + if len(lines) > 1: + dup_report[full][kind].append((key, lines)) + # Print + for file, kinds in dup_report.items(): + print(f"\n[ARQUIVO] {file}") + for kind, dups in kinds.items(): + for key, lines in dups: + print(f" - {kind}: key='{key}' duplicada nas linhas {lines}") + +if __name__ == "__main__": + main(".") diff --git a/start_streamlit.py b/start_streamlit.py new file mode 100644 index 0000000000000000000000000000000000000000..ce8d461ab2a32921f77ee76b6aa6ce62a1427d22 --- /dev/null +++ b/start_streamlit.py @@ -0,0 +1,82 @@ +import os +import sys +import subprocess +import signal +import time + +# === Basic settings === +APP_FILE = os.environ.get("STREAMLIT_APP", "app.py") +PORT = int(os.environ.get("PORT", "8000")) # Passenger typically proxies to 8000 +CWD = os.path.dirname(os.path.abspath(__file__)) +PYTHON = sys.executable # python from the cPanel virtualenv +LOGFILE = os.path.join(CWD, "streamlit.log") +LOCKFILE = os.path.join(CWD, "streamlit.lock") + +# Optional: tweak Streamlit via env vars (safe defaults) +os.environ.setdefault("STREAMLIT_BROWSER_GATHERUSAGESTATS", "false") +os.environ.setdefault("STREAMLIT_SERVER_HEADLESS", "true") + +# --- Helpers --- +def _is_running(pid: int) -> bool: + try: + os.kill(pid, 0) + return True + except Exception: + return False + + +def _read_pid(): + try: + with open(LOCKFILE, "r") as f: + return int(f.read().strip()) + except Exception: + return None + + +def _write_pid(pid: int): + with open(LOCKFILE, "w") as f: + f.write(str(pid)) + + +def _start_streamlit(): + # Prevent multiple instances + old_pid = _read_pid() + if old_pid and _is_running(old_pid): + return + + cmd = [ + PYTHON, "-m", "streamlit", "run", APP_FILE, + "--server.port", str(PORT), + "--server.address", "0.0.0.0", + "--server.headless", "true", + ] + + os.makedirs(os.path.dirname(LOGFILE), exist_ok=True) if \ + os.path.dirname(LOGFILE) else None + + with open(LOGFILE, "a", buffering=1) as log: + log.write(f"\n--- Starting Streamlit at {time.ctime()} ---\n") + log.write(f"Command: {' '.join(cmd)}\n") + proc = subprocess.Popen( + cmd, + cwd=CWD, + stdout=log, + stderr=log, + preexec_fn=os.setsid # start new process group + ) + _write_pid(proc.pid) + + +# Start Streamlit as soon as Passenger imports this module +_start_streamlit() + +# Minimal WSGI app to respond while Streamlit boots +def application(environ, start_response): + status = "200 OK" + headers = [("Content-Type", "text/plain; charset=utf-8")] + start_response(status, headers) + msg = ( + "Streamlit is starting... " + "If you still see this after ~20s, refresh the page or check streamlit.log." + ) + return [msg.encode("utf-8")] \ No newline at end of file diff --git a/sugestoes_usuario.py b/sugestoes_usuario.py new file mode 100644 index 0000000000000000000000000000000000000000..6b0561c58d9713a0ab866c1bb09c86d6c509ae32 --- /dev/null +++ b/sugestoes_usuario.py @@ -0,0 +1,257 @@ + +# -*- coding: utf-8 -*- +import streamlit as st +from datetime import datetime, timedelta +from sqlalchemy import func +from models import IOIRunSugestao + +try: + from utils_auditoria import registrar_log +except Exception: + registrar_log = None + + +# ========================================================== +# Sessão de banco — ciente do ambiente +# ========================================================== +def _get_db_session(): + try: + from db_router import get_session_for_current_db + return get_session_for_current_db() + except Exception: + pass + try: + from banco import SessionLocal + return SessionLocal() + except Exception as e: + st.error(f"Banco indisponível: {e}") + raise + + +# ========================================================== +# Helpers +# ========================================================== +STATUS_PENDENTE = "pendente" +STATUS_RESPONDIDA = "respondida" + +DEDUP_MINUTOS = 5 # janela contra duplicatas (mesmo usuário + mesma mensagem) + +def _normalize_text(s: str) -> str: + """Normaliza a mensagem para comparação de duplicidade (casefold + collapse spaces).""" + if not s: + return "" + # remove espaços redundantes e baixa/casefold + collapsed = " ".join(s.split()) + return collapsed.casefold() + + +# =============================== +# MÓDULO DO USUÁRIO — Sugestões IOI‑RUN +# =============================== +def main(): + usuario = st.session_state.get("usuario") + perfil = (st.session_state.get("perfil") or "usuario").strip().lower() + + st.title("💡 Sugestões para o IOI‑RUN") + + if not usuario: + st.info("Faça login para enviar sugestões e ver seu histórico.") + return + + # ---------------------------------------------------------- + # ✅ Mensagem de sucesso persistente pós-rerun (popup “+tempo”) + # ---------------------------------------------------------- + if st.session_state.get("sug_envio_ok"): + st.success(st.session_state.get("sug_envio_msg") or "Sua sugestão foi enviada! Assim que houver resposta, você será avisado.") + # (Opcional) Toast no Streamlit >= 1.25 + # st.toast("Sugestão enviada! Obrigado por contribuir 🙌", icon="✅") + # Limpa o flag depois de exibir + st.session_state["sug_envio_ok"] = False + st.session_state["sug_envio_msg"] = None + + # ---------------------------------------------------------- + # FORMULÁRIO PARA ENVIAR SUGESTÃO + # ---------------------------------------------------------- + st.subheader("📨 Envie uma sugestão") + + # Estados iniciais para travas/inputs + st.session_state.setdefault("sug_enviando", False) + st.session_state.setdefault("sug_msg_input", "") + st.session_state.setdefault("sug_area_input", "") + + with st.form("form_sugestao_usuario"): + col_a, col_b = st.columns([2, 1]) + + mensagem = col_a.text_area( + "Sua sugestão:", + key="sug_msg_input", # ✅ key persistente + placeholder="Descreva sua ideia, problema ou melhoria…", + height=160 + ) + + area = col_b.selectbox( + "Área/Tema (opcional)", + ["", "WMS", "FPSO", "UI/UX", "Relatórios", "Integrações", "Performance", "Segurança", "Outros"], + key="sug_area_input" # ✅ key persistente + ) + + enviar = st.form_submit_button( + "Enviar sugestão", + disabled=st.session_state.get("sug_enviando", False) # ✅ trava anti-duplo clique + ) + + if enviar: + # Anti-clic duplo: se já está enviando, ignora + if st.session_state.get("sug_enviando"): + st.stop() + st.session_state["sug_enviando"] = True + + texto_raw = (mensagem or "") + texto = texto_raw.strip() + + if len(texto) < 10: + st.session_state["sug_enviando"] = False + st.warning("A sugestão está curta. Tente detalhar mais (mínimo 10 caracteres).") + else: + db = _get_db_session() + try: + # ---------- Deduplicação (mesmo usuário + mesma mensagem normalizada) ---------- + now = datetime.now() + cutoff = now - timedelta(minutes=DEDUP_MINUTOS) + texto_norm = _normalize_text(texto) + + duplicated = ( + db.query(IOIRunSugestao.id) + .filter( + IOIRunSugestao.usuario == usuario, + func.lower(func.trim(IOIRunSugestao.mensagem)) != "", # segurança + IOIRunSugestao.data_envio >= cutoff + ) + .all() + ) + is_dup = False + if duplicated: + # Busca em memória (normalização robusta) + from sqlalchemy.orm import load_only + dup_msgs = ( + db.query(IOIRunSugestao) + .options(load_only(IOIRunSugestao.mensagem)) + .filter( + IOIRunSugestao.usuario == usuario, + IOIRunSugestao.data_envio >= cutoff + ) + .all() + ) + for d in dup_msgs: + if _normalize_text(d.mensagem or "") == texto_norm: + is_dup = True + break + + if is_dup: + st.info(f"Detectamos que você enviou essa mesma sugestão nos últimos {DEDUP_MINUTOS} minutos. Obrigado! 🙌") + # Limpa campos mesmo assim (evita reenvio por engano) + st.session_state["sug_msg_input"] = "" + st.session_state["sug_area_input"] = "" + st.session_state["sug_enviando"] = False + st.stop() + + # ---------- Inserção ---------- + nova = IOIRunSugestao( + usuario=usuario, + area=area if area else None, + mensagem=texto, + status=STATUS_PENDENTE, + data_envio=now + ) + db.add(nova) + db.commit() + db.refresh(nova) + + if registrar_log: + try: + registrar_log(usuario, "ENVIAR_SUGESTAO_IOIRUN", "ioirun_sugestao", nova.id) + except Exception: + pass + + # ✅ Marca sucesso persistente (vai aparecer após o rerun) + st.session_state["sug_envio_ok"] = True + st.session_state["sug_envio_msg"] = "Sua sugestão foi enviada! Assim que houver resposta, você será avisado. ✅" + + # ✅ Limpa os campos do formulário (evita reenviar igual) + st.session_state["sug_msg_input"] = "" + st.session_state["sug_area_input"] = "" + + st.session_state["sug_enviando"] = False + st.rerun() + + except Exception as e: + db.rollback() + st.session_state["sug_enviando"] = False + st.error(f"Erro ao salvar sugestão: {e}") + finally: + try: + db.close() + except Exception: + pass + + st.divider() + + # ---------------------------------------------------------- + # HISTÓRICO DO USUÁRIO + RESPOSTAS DO TIME IOI‑RUN + # ---------------------------------------------------------- + st.subheader("📂 Histórico e Respostas") + + dbh = _get_db_session() + try: + registros = ( + dbh.query(IOIRunSugestao) + .filter(IOIRunSugestao.usuario == usuario) + .order_by(IOIRunSugestao.data_envio.desc()) + .all() + ) + except Exception: + registros = [] + + if not registros: + st.info("Você ainda não enviou sugestões. Envie a primeira acima!") + else: + # Filtra respondidas com case-insensitive + respostas = [r for r in registros if r.status and r.data_resposta and r.status.lower() == STATUS_RESPONDIDA] + ultima_resposta = max((r.data_resposta for r in respostas), default=None) + + if ultima_resposta: + st.session_state.user_responses_viewed = True + st.session_state["__user_last_answer_seen__"] = ultima_resposta + + for item in registros: + dt_envio = item.data_envio.strftime('%d/%m/%Y %H:%M') if item.data_envio else "—" + titulo = f"📌 {dt_envio} — Status: {(item.status or '').upper()}" + if item.area: + titulo += f" — Área: {item.area}" + + with st.expander(titulo): + st.markdown("**Sugestão enviada:**") + st.write(item.mensagem or "—") + + if (item.status or "").lower() == STATUS_RESPONDIDA: + st.markdown("---") + st.markdown("**Resposta do time IOI‑RUN:**") + st.success(item.resposta or "—") + + rodape = [] + if item.responsavel: + rodape.append(f"por: {item.responsavel}") + if item.data_resposta: + rodape.append(f"em: {item.data_resposta.strftime('%d/%m/%Y %H:%M')}") + if rodape: + st.caption(" | ".join(rodape)) + else: + st.info("Aguardando resposta…") + + try: + dbh.close() + except Exception: + pass + + st.markdown("---") + st.caption("Use o menu lateral para navegar para outros módulos.") diff --git a/tabela aviso_global b/tabela aviso_global new file mode 100644 index 0000000000000000000000000000000000000000..0bbb4aab0cf3e22b79eaabb64baed912a27b5930 --- /dev/null +++ b/tabela aviso_global @@ -0,0 +1,23 @@ + +# ⬇️ NOVO: Imports para o modelo de aviso global +from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text, engine +from sqlalchemy.sql import func + +# ⬇️ NOVO: Modelo para o aviso global (banner superior) +class AvisoGlobal(Base): + __tablename__ = "aviso_global" + id = Column(Integer, primary_key=True, index=True) + mensagem = Column(Text, nullable=False) + bg_color = Column(String(32), default="#FFF3CD") + text_color = Column(String(32), default="#664D03") + largura = Column(String(16), default="100%") # "100%" ou "1200px" + efeito = Column(String(16), default="marquee") # 'marquee' ou 'fixo' + velocidade = Column(Integer, default=20) # segundos por ciclo (marquee) + ativo = Column(Boolean, default=True, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now()) + + +# ⬇️ NOVO: garante criação da tabela 'aviso_global' sem afetar as já existentes +Base.metadata.create_all(bind=engine) + diff --git a/usuarios_admin.py b/usuarios_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..c6c9193dc3af6a5abb75d688a9005e5429a0052e --- /dev/null +++ b/usuarios_admin.py @@ -0,0 +1,385 @@ + +import streamlit as st +from banco import SessionLocal +from models import Usuario +from utils_seguranca import gerar_hash_senha +from utils_auditoria import registrar_log +import re # ✅ para validação simples de e-mail +from datetime import date # ✅ para trabalhar com st.date_input + + +def email_valido(email: str) -> bool: + """ + Validação simples do formato de e-mail. + Evita espaços e verifica partes básicas (usuario@dominio.tld). + """ + if not email: + return False + padrao = r"^[^@\s]+@[^@\s]+\.[^@\s]+$" + return re.match(padrao, email.strip()) is not None + + +def main(): + st.title("👥 Administração de Usuários") + + # 🔐 Segurança extra + if st.session_state.get("perfil") != "admin": + st.error("❌ Acesso restrito ao administrador.") + return + + db = SessionLocal() + + try: + # ========================== + # CADASTRO DE NOVO USUÁRIO + # ========================== + st.subheader("➕ Cadastrar novo usuário") + + with st.form("form_novo_usuario"): + usuario = st.text_input("Usuário") + senha = st.text_input("Senha", type="password") + + perfil = st.selectbox( + "Perfil", + ["admin", "usuario", "consulta"] + ) + + ativo = st.checkbox("Usuário ativo", value=True) + + # ✅ Novo campo: e-mail + email = st.text_input("E-mail (corporativo)", placeholder="nome.sobrenome@empresa.com") + + # ✅ NOVO: Data de aniversário (opcional) com limites explícitos + key único + data_aniversario = st.date_input( + "Data de aniversário (opcional)", + value=None, # deixa sem valor inicial + format="DD/MM/YYYY", + min_value=date(1900, 1, 1), + max_value=date.today(), + key="data_aniv_novo" # 🔑 força widget novo com limites corretos + ) + + salvar = st.form_submit_button("💾 Criar usuário") + + if salvar: + # Validação mínima + if not usuario or not senha: + st.warning("⚠️ Preencha usuário e senha.") + elif not email or not email_valido(email): + st.warning("⚠️ Informe um e-mail válido.") + else: + existe_login = db.query(Usuario).filter(Usuario.usuario == usuario).first() + existe_email = db.query(Usuario).filter(Usuario.email == email.strip()).first() + + if existe_login: + st.error("❌ Usuário já existe.") + elif existe_email: + st.error("❌ E-mail já cadastrado para outro usuário.") + else: + novo = Usuario( + usuario=usuario, + senha=gerar_hash_senha(senha), + perfil=perfil, + ativo=ativo, + email=email.strip() # ✅ grava e-mail + ) + + # ✅ NOVO: grava data de aniversário se seu models suportar + # - Se 'data_aniversario' vier de st.date_input como 'date' do Python, + # vamos converter para string ISO (YYYY-MM-DD) se seu models usar String, + # ou atribuir diretamente se seu models usar Date. + if data_aniversario: + try: + # tenta atribuir diretamente (funciona se o campo for Date) + setattr(novo, "data_aniversario", data_aniversario) + except Exception: + try: + # fallback para String ISO (YYYY-MM-DD) + setattr(novo, "data_aniversario", data_aniversario.isoformat()) + except Exception: + # se o atributo não existir, não quebra a criação + pass + + db.add(novo) + db.commit() + db.refresh(novo) # 🔑 garante ID + + # 🧾 AUDITORIA CORRETA + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"Criou usuário {usuario} (email: {email.strip()})", + tabela="usuarios", + registro_id=novo.id + ) + + st.success("✅ Usuário criado com sucesso!") + st.rerun() + + st.divider() + + # ========================== + # LISTAGEM DE USUÁRIOS + # ========================== + st.subheader("📋 Usuários cadastrados") + + usuarios = db.query(Usuario).order_by(Usuario.usuario).all() + + if not usuarios: + st.info("Nenhum usuário cadastrado.") + return + + # 🔸 Estados auxiliares para edição/exclusão + if "edit_user_id" not in st.session_state: + st.session_state.edit_user_id = None + + for u in usuarios: + # 👤 Cabeçalho do usuário com perfil + with st.expander(f"👤 {u.usuario} ({u.perfil})"): + col1, col2 = st.columns(2) + + with col1: + st.write(f"**Perfil:** {u.perfil}") + st.write(f"**Ativo:** {'✅ Sim' if u.ativo else '❌ Não'}") + # ✅ Mostra o e-mail, se houver + st.write(f"**E-mail:** {u.email or '—'}") + + # ✅ NOVO: Mostra a data de aniversário, se houver + try: + # tenta formatar se for objeto date + if getattr(u, "data_aniversario", None): + if isinstance(u.data_aniversario, date): + st.write(f"**Aniversário:** {u.data_aniversario.strftime('%d/%m/%Y')}") + else: + # se for string ISO (YYYY-MM-DD) + st.write(f"**Aniversário:** {str(u.data_aniversario)}") + else: + st.write("**Aniversário:** —") + except Exception: + st.write("**Aniversário:** —") + + with col2: + novo_status = st.checkbox( + "Usuário ativo", + value=u.ativo, + key=f"ativo_{u.id}" + ) + + if novo_status != u.ativo: + u.ativo = novo_status + db.commit() + + # 🧾 AUDITORIA STATUS + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"{'Ativou' if novo_status else 'Desativou'} usuário {u.usuario}", + tabela="usuarios", + registro_id=u.id + ) + + st.success("Status atualizado") + st.rerun() + + st.markdown("---") + + # ========================== + # ✏️ BOTÕES: EDITAR e EXCLUIR + # ========================== + col_e1, col_e2 = st.columns([1, 1]) + + # Clicar em "Editar" abre o formulário desse usuário + if col_e1.button("✏️ Editar", key=f"btn_editar_{u.id}"): + st.session_state.edit_user_id = u.id + # 🔧 defensivo: limpa qualquer estado antigo do date_input desta linha + st.session_state.pop(f"data_aniv_{u.id}", None) + st.rerun() + + # Clicar em "Excluir" abre confirmação + if col_e2.button("🗑️ Excluir", key=f"btn_excluir_{u.id}"): + st.session_state[f"show_delete_{u.id}"] = True + st.rerun() + + # ========================== + # ✏️ FORMULÁRIO DE EDIÇÃO (por usuário) + # ========================== + if st.session_state.edit_user_id == u.id: + with st.form(key=f"form_editar_{u.id}"): + st.subheader("✏️ Editar usuário") + + novo_usuario = st.text_input("Usuário (login)", value=u.usuario) + novo_nome = st.text_input("Nome completo (opcional)", value=(u.nome or "")) + novo_email = st.text_input("E-mail (corporativo)", value=(u.email or "")) + + novo_perfil = st.selectbox( + "Perfil", + ["admin", "usuario", "consulta"], + index=["admin", "usuario", "consulta"].index(u.perfil) + ) + + novo_ativo = st.checkbox("Usuário ativo", value=u.ativo) + + # ✅ NOVO: Data de aniversário (opcional) na edição + # Inicializa valor padrão de forma segura + valor_data = None + try: + if getattr(u, "data_aniversario", None): + if isinstance(u.data_aniversario, date): + valor_data = u.data_aniversario + else: + # tentativa de parse se for string ISO (YYYY-MM-DD) + # (mantemos simples para não quebrar: se parse falhar, continua None) + parts = str(u.data_aniversario).split("-") + if len(parts) == 3: + yy, mm, dd = map(int, parts) + valor_data = date(yy, mm, dd) + except Exception: + valor_data = None + + # 🔧 Se o valor atual estiver fora dos limites novos, usa None + min_d, max_d = date(1900, 1, 1), date.today() + if valor_data and not (min_d <= valor_data <= max_d): + valor_data = None + + novo_aniversario = st.date_input( + "Data de aniversário (opcional)", + value=valor_data, + format="DD/MM/YYYY", + min_value=min_d, # ⬅️ permite anos antigos + max_value=max_d, # ⬅️ bloqueia datas futuras + key=f"data_aniv_{u.id}" # 🔑 key único por usuário evita reuso de estado + ) + + st.markdown("**🔒 Reset de senha (opcional)**") + nova_senha = st.text_input("Nova senha", type="password", help="Preencha para redefinir a senha do usuário.") + confirm_senha = st.text_input("Confirme a nova senha", type="password") + + salvar_edicao = st.form_submit_button("💾 Salvar alterações") + cancelar_edicao = st.form_submit_button("❌ Cancelar edição") + + # Cancela edição sem perder a lista + if cancelar_edicao: + st.session_state.edit_user_id = None + st.info("Edição cancelada.") + st.rerun() + + if salvar_edicao: + # Validações + if novo_email and not email_valido(novo_email): + st.warning("⚠️ Informe um e-mail válido.") + elif nova_senha and (nova_senha.strip() != confirm_senha.strip()): + st.error("❌ As senhas não conferem.") + else: + try: + # Verifica conflito de login + if novo_usuario.strip() != u.usuario: + conflito_login = db.query(Usuario).filter(Usuario.usuario == novo_usuario.strip()).first() + if conflito_login and conflito_login.id != u.id: + st.error("❌ Já existe outro usuário com esse login.") + st.stop() + + # Verifica conflito de e-mail + if novo_email.strip() and (novo_email.strip() != (u.email or "").strip()): + conflito_email = db.query(Usuario).filter(Usuario.email == novo_email.strip()).first() + if conflito_email and conflito_email.id != u.id: + st.error("❌ E-mail já cadastrado para outro usuário.") + st.stop() + + # Atualiza campos no objeto atual + u.usuario = novo_usuario.strip() + u.nome = (novo_nome or "").strip() or None + u.email = novo_email.strip() or None + u.perfil = novo_perfil + u.ativo = novo_ativo + + # ✅ NOVO: Atualiza data de aniversário se fornecida + if novo_aniversario: + try: + # se o campo for Date, funciona direto + setattr(u, "data_aniversario", novo_aniversario) + except Exception: + try: + # fallback para String ISO + setattr(u, "data_aniversario", novo_aniversario.isoformat()) + except Exception: + pass + else: + # permite limpar o campo (definir None) + try: + setattr(u, "data_aniversario", None) + except Exception: + pass + + # Reset de senha, se informado + if nova_senha.strip(): + u.senha = gerar_hash_senha(nova_senha.strip()) + + # ✅ Merge para garantir que a sessão aplique mudanças de forma robusta + db.merge(u) + db.commit() + + # Auditoria + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"Editou usuário {u.usuario}", + tabela="usuarios", + registro_id=u.id + ) + + st.success("✅ Usuário atualizado com sucesso!") + # Mantém o formulário aberto após o rerun (mesmo user) para visibilidade + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Falha ao atualizar usuário: {e}") + + # ========================== + # 🗑️ CONFIRMAÇÃO DE EXCLUSÃO (por usuário) + # ========================== + if st.session_state.get(f"show_delete_{u.id}"): + with st.expander("⚠️ Confirmar exclusão", expanded=True): + st.warning( + "Esta ação irá **excluir definitivamente** o usuário selecionado. " + "Não há como desfazer." + ) + confirmar = st.checkbox("Eu entendo o risco e desejo prosseguir.", key=f"chk_del_{u.id}") + codigo = st.text_input( + f"Digite `DELETE {u.usuario}` para confirmar:", + key=f"txt_del_{u.id}" + ) + pode_excluir = confirmar and (codigo.strip() == f"DELETE {u.usuario}") + + col_x1, col_x2 = st.columns([1, 1]) + if col_x1.button("🗑️ Excluir usuário", disabled=not pode_excluir, type="primary", key=f"btn_del_conf_{u.id}"): + try: + # Evita excluir a si próprio (boa prática) + if u.usuario == st.session_state.get("usuario"): + st.error("❌ Você não pode excluir a si mesmo enquanto está logado.") + else: + db.delete(u) + db.commit() + + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"Excluiu usuário {u.usuario}", + tabela="usuarios", + registro_id=u.id + ) + + st.success("✅ Usuário excluído com sucesso.") + st.session_state[f"show_delete_{u.id}"] = False + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Falha ao excluir usuário: {e}") + + if col_x2.button("❌ Cancelar", key=f"btn_del_cancel_{u.id}"): + st.session_state[f"show_delete_{u.id}"] = False + st.info("Exclusão cancelada.") + st.rerun() + + finally: + db.close() + + + + + diff --git a/utils_auditoria.py b/utils_auditoria.py new file mode 100644 index 0000000000000000000000000000000000000000..49809ea4fdd12a17dd8e3e2ea5f5624193f4a1cb --- /dev/null +++ b/utils_auditoria.py @@ -0,0 +1,76 @@ + +# utils_auditoria.py +# -*- coding: utf-8 -*- + +from datetime import datetime +from sqlalchemy.exc import SQLAlchemyError + +from banco import SessionLocal, db_info +from models import LogAcesso + +# 🔎 (Opcional) Se quiser forçar a leitura do ambiente atual ("prod"/"test") para exibir/registrar: +try: + from db_router import current_db_choice + _HAS_ROUTER = True +except Exception: + _HAS_ROUTER = False + def current_db_choice() -> str: + # fallback: sem roteador, considere produção + return "prod" + + +def registrar_log(usuario, acao, tabela=None, registro_id=None, ambiente=None): + """ + Registra ações relevantes no sistema para auditoria. + • Sempre grava no BANCO ATIVO exposto por banco.SessionLocal (Produção ou Teste), + conforme a escolha feita no login (via db_router.set_db_choice). + + Parâmetros: + usuario (str) - identificador do usuário + acao (str) - descrição da ação (ex.: 'Login realizado com sucesso') + tabela (str) - (opcional) nome lógico da entidade (ex.: 'usuarios', 'operacao') + registro_id (int) - (opcional) id do registro afetado + ambiente (str) - (opcional) 'prod' ou 'test'; se não for informado, + será inferido automaticamente quando db_router estiver disponível. + + Observações: + - Se o seu modelo LogAcesso TIVER a coluna 'ambiente', ela será preenchida. + Caso não tenha, a atribuição é ignorada silenciosamente (compatibilidade). + - Evite prints fixos de caminho de arquivo; use db_info() se precisar depurar. + """ + + # Ambiente: se não veio, tenta inferir do roteador; senão, 'prod' (fallback) + ambiente_atual = (ambiente or (current_db_choice() if _HAS_ROUTER else "prod")) + + # (Opcional) debug rápido do banco ativo — mantenha comentado se não precisar: + # info = db_info() + # print(f"🗄️ Auditoria → banco ativo: {info.get('url')} | router={info.get('using_router')} | ambiente={ambiente_atual}") + + db = SessionLocal() + try: + log = LogAcesso( + usuario=usuario, + acao=acao, + tabela=tabela, + registro_id=registro_id, + data_hora=datetime.now() + ) + + # Preenche 'ambiente' apenas se o modelo tiver essa coluna (compatibilidade) + if hasattr(log, "ambiente"): + setattr(log, "ambiente", ambiente_atual) + + db.add(log) + db.commit() # 🔥 OBRIGATÓRIO + except SQLAlchemyError as e: + db.rollback() + # Log leve para diagnóstico; ajuste para seu logger se existir + print("❌ ERRO AO REGISTRAR LOG:", str(e)) + except Exception as e: + db.rollback() + print("❌ ERRO INESPERADO AO REGISTRAR LOG:", str(e)) + finally: + db.close() + + + diff --git a/utils_campos.py b/utils_campos.py new file mode 100644 index 0000000000000000000000000000000000000000..eccb4afb77786861b381514d91d2cd2bed4c768b --- /dev/null +++ b/utils_campos.py @@ -0,0 +1,38 @@ +import streamlit as st +from banco import SessionLocal +from models import Equipamento + + +@st.cache_data +def listar_valores_distintos(campo): + db = SessionLocal() + try: + valores = ( + db.query(campo) + .distinct() + .all() + ) + return sorted({v[0] for v in valores if v[0]}) + finally: + db.close() + + +def campo_select_sugerido(label, campo_model, valor_atual=None): + """ + Campo com: + - sugestões do banco + - valor atual garantido + - seleção opcional + """ + + sugestoes = [""] + listar_valores_distintos(campo_model) + + # garante exibição de valor antigo + if valor_atual and valor_atual not in sugestoes: + sugestoes.append(valor_atual) + + return st.selectbox( + label, + sugestoes, + index=sugestoes.index(valor_atual) if valor_atual in sugestoes else 0 + ) diff --git a/utils_datas.py b/utils_datas.py new file mode 100644 index 0000000000000000000000000000000000000000..4d2c16fed19ad6e60a5a016cdca390acec161cc9 --- /dev/null +++ b/utils_datas.py @@ -0,0 +1,12 @@ +from datetime import date, datetime + +def formatar_data_br(data): + """ + Retorna data no formato brasileiro dd/mm/aaaa + Aceita date, datetime ou None + """ + if not data: + return "-" + if isinstance(data, datetime): + return data.strftime("%d/%m/%Y %H:%M") + return data.strftime("%d/%m/%Y") diff --git a/utils_fpso.py b/utils_fpso.py new file mode 100644 index 0000000000000000000000000000000000000000..711f9fd5ae247283bbcf03448023943d31c1c7d9 --- /dev/null +++ b/utils_fpso.py @@ -0,0 +1,24 @@ +import streamlit as st +from banco import SessionLocal +from models import FPSO + + +@st.cache_data +def listar_fpsos(): + db = SessionLocal() + try: + return [f.nome for f in db.query(FPSO).order_by(FPSO.nome).all()] + finally: + db.close() + + +def campo_fpso(label, valor_atual=None): + fpsos = [""] + listar_fpsos() + + return st.selectbox( + label, + fpsos, + index=fpsos.index(valor_atual) if valor_atual in fpsos else 0 + ) + + diff --git a/utils_info.py b/utils_info.py new file mode 100644 index 0000000000000000000000000000000000000000..646f63ec5509df11434b51c3e9a9aa34615ee370 --- /dev/null +++ b/utils_info.py @@ -0,0 +1,184 @@ + +# -*- coding: utf-8 -*- +""" +utils_info.py +Conteúdos de ajuda (Info) por módulo para exibir no sidebar do app. +Estruture e ajuste os textos conforme necessidade. +""" + +# Lista de módulos disponíveis para Info (exibição no selectbox do sidebar) +INFO_MODULOS = [ + "Geral", + "Consulta", + "Calendário", + "Produtividade por Especialista", + "Jogos", + "Auditoria Cleanup", + "Importação de Excel", + "Relatório", + "Ranking", + "Administração", + "Usuários", + "Operação", + "DB Admin / Monitor / Export-Import", +] + +# Conteúdo Markdown por módulo (pode mover para arquivos externos se crescer) +INFO_CONTEUDO = { + "Geral": """ +**Objetivo:** Navegar pelo sistema, acessar módulos e exportar dados. + +**Passo a passo:** +1. No sidebar, use **Pesquisar módulo** para localizar rapidamente. +2. Selecione a **Operação** (grupo) e o **Módulo** desejado. +3. Para sair, use **🚪 Sair (Logout)** no final do menu. + +**Dicas:** +- A legenda do **Banco ativo** (Produção/Teste) aparece abaixo do menu. +- Alguns módulos exigem **permissão** (via `utils_permissoes.verificar_permissao`). +""", + + "Consulta": """ +**Objetivo:** Pesquisar e exportar registros de Equipamentos. + +**Passo a passo:** +1. Selecione **Consulta** no menu. +2. Aplique **filtros**: FPSO, Modal, Especialista, Conferente, Período, Dia Inclusão. +3. Confira os **resultados** e use **⬇️ Exportar para Excel**. + +**Campos:** +- **FPSO**: seleção múltipla; valores do cadastro. +- **Período de Coleta**: intervalo de datas (início e fim). +- **Dia Inclusão**: D1/D2/D3. +""", + + "Calendário": """ +**Objetivo:** Criar, visualizar e gerenciar eventos/observações. + +**Passo a passo:** +1. Preencha o **formulário** de novo evento/lembrete. +2. Clique no **dia** ou **evento** para abrir observações. +3. Admin pode **Desativar/Excluir** e gera log de auditoria. + +**Cores (legenda):** Verde (ativo futuro) • Laranja (lembrete hoje) • Vermelho (passado) • Cinza (inativo). +""", + + "Produtividade por Especialista": """ +**Objetivo:** Acompanhar produtividade por **Especialista** e **Conferente**, com metas distintas. + +**Passo a passo:** +1. No **sidebar**: ative **Atualização automática** e defina o **Intervalo** (segundos), depois clique **✅ Aplicar intervalo**. +2. Defina as **Metas de % Acertos (MROB/Geral)** separadas (Especialistas e Conferentes). +3. Aplique **filtros** (FPSO, Modal, Período). +4. Veja **KPIs Gerais**: Geral (Todos), Geral (Especialistas), Geral (Conferentes). + - % Acertos aparece **verde** se ≥ meta e **vermelho** se < meta. + - % Erros aparece **vermelho**. +5. Consulte **KPIs por Especialista** e **por Conferente**. +6. Exportações: **Produtividade por Especialista** e **por Conferente**. + +**Métricas:** +- **Linhas OSM** = total OSM. +- **Linhas MROB** = total MROB. +- **Erros MROB** = total de erros MROB (fallback para “Linhas Erros” genérico). +- **Total de Erros** = MROB − Erros MROB (clip ≥ 0). +- **% Acertos (MROB)** = (MROB − Erros MROB) / MROB. +- **% Erros (MROB)** = Erros MROB / MROB. + +**Observações:** +- Sidebar mostra **Última atualização**, **Atualizado há …** e **Próximo refresh**. +""", + + "Jogos": """ +**Objetivo:** Treinamento gamificado (Dado, Forca, Caça ao Tesouro). + +**Passo a passo:** +1. Escolha o jogo e siga as instruções. +2. Pontuações e progresso são mantidos em `st.session_state`. +""", + + "Auditoria Cleanup": """ +**Objetivo:** Excluir logs antigos com confirmação explícita. + +**Passo a passo:** +1. Escolha o **período**. +2. Confira a **prévia** (count). +3. Confirme (SIM) via selectbox ou clique em **✅ Confirmar exclusão**. +4. Acompanhe a mensagem de sucesso/erro e o log gerado. + +**Observações:** +- Em falhas, `rollback()` preserva integridade. +""", + + "Importação de Excel": """ +**Objetivo:** Importar planilhas (XLSX), validar e carregar dados. + +**Passo a passo:** +1. Faça upload do arquivo Excel. +2. Valide colunas/campos obrigatórios. +3. Execute a importação e confira o resumo. +""", + + "Relatório": """ +**Objetivo:** Visualizar relatórios analíticos e exportar. + +**Passo a passo:** +1. Aplique filtros e selecione o escopo do relatório. +2. Gere visualizações e exporte. +""", + + "Ranking": """ +**Objetivo:** Classificar produtividade por critérios (contagem, soma, score). + +**Passo a passo:** +1. Escolha **Agrupar por** (Especialista, Conferente, FPSO, etc.). +2. Selecione o **Critério** (registros, soma, score). +3. Ajuste o **Top N** e exporte. +""", + + "Administração": """ +**Objetivo:** Configurações administrativas, permissões e gestão. + +**Passo a passo:** +1. Utilize funções administrativas conforme perfil. +""", + + "Usuários": """ +**Objetivo:** Gerenciar usuários, perfis e acessos. + +**Passo a passo:** +1. Cadastre/edite usuários e perfis. +""", + + "Operação": """ +**Objetivo:** Funções operacionais específicas (Load/Backload/etc.). + +**Passo a passo:** +1. Execute as operações conforme processo interno. +""", + + "DB Admin / Monitor / Export-Import": """ +**Objetivo:** Administração, monitoramento e exportação/importação de dados. + +**Passo a passo:** +1. Use com cautela; operações podem afetar produção. +""" +} + +# Mapeamento pagina_id → nome exibido no Info (ajuste conforme seu modules_map) +INFO_MAP_PAGINA_ID = { + "consulta": "Consulta", + "calendario": "Calendário", + "produtividade_especialista": "Produtividade por Especialista", + "jogos": "Jogos", + "auditoria_cleanup": "Auditoria Cleanup", + "importacao": "Importação de Excel", + "relatorio": "Relatório", + "ranking": "Ranking", + "administracao": "Administração", + "usuarios": "Usuários", + "operacao": "Operação", + "db_admin": "DB Admin / Monitor / Export-Import", + "db_monitor": "DB Admin / Monitor / Export-Import", + "db_export_import": "DB Admin / Monitor / Export-Import", + # Fallbacks e demais módulos que você tiver +} diff --git a/utils_layout.py b/utils_layout.py new file mode 100644 index 0000000000000000000000000000000000000000..1cc9e3e9d56f9a4e1b7363e04d9baf7d01e427ca --- /dev/null +++ b/utils_layout.py @@ -0,0 +1,29 @@ +import streamlit as st +from PIL import Image +import os + + +def exibir_logo(top=True, sidebar=True): + logo_path = os.path.join("assets", "logo.png") + + if not os.path.exists(logo_path): + return + + logo = Image.open(logo_path) + + # ========================== + # LOGO PRINCIPAL (TOPO) + # ========================== + if top: + col1, col2, col3 = st.columns([2, 3, 2]) # mais espaço lateral + with col2: + st.image(logo, width=500) # 👈 menor, mas ainda legível + + # ========================== + # SIDEBAR (NÃO ALTERADO) + # ========================== + if sidebar: + st.sidebar.image( + logo, + use_container_width=True + ) diff --git a/utils_lembretes.py b/utils_lembretes.py new file mode 100644 index 0000000000000000000000000000000000000000..938d6cda2f8c9890fbac4a54d5ac890afbc9330c --- /dev/null +++ b/utils_lembretes.py @@ -0,0 +1,13 @@ +from datetime import date, timedelta + + +def datas_lembrete_padrao(data_evento): + """ + Retorna datas automáticas de lembrete: + - D-1 + - D-3 + """ + return [ + data_evento - timedelta(days=1), + data_evento - timedelta(days=3), + ] diff --git a/utils_operacao.py b/utils_operacao.py new file mode 100644 index 0000000000000000000000000000000000000000..00eefdd491514be8688962067a4c707464c68679 --- /dev/null +++ b/utils_operacao.py @@ -0,0 +1,188 @@ + +# -*- coding: utf-8 -*- +""" +utils_operacao.py + +Utilitários para controlar a visibilidade do que o usuário vê no sidebar: +- Quais operações (grupos) aparecem +- Quais módulos aparecem por operação + +Regras aplicadas: +1) Permissão declarada no MODULES[mod_id]["perfis"] +2) Regra customizável por ambiente (prod/test) via "somente_prod" +3) Feature flag opcional via "feature_flag" +4) Janela de horário opcional via "janela_horario" (ex.: {"inicio":"08:00","fim":"18:00"}) +5) Whitelist/Blacklist por usuário ("usuarios_permitidos" / "usuarios_bloqueados") +6) Módulo em manutenção via "manutencao" (True/False) + +✅ Todas as chaves acima são OPCIONAIS ao MODULES. Se não existirem, não bloqueiam. +""" + +from __future__ import annotations +import os +from datetime import datetime, time as dtime +from typing import Dict, List, Tuple, Optional, Callable + +import streamlit as st + + +# =============================== +# LEITORES AUXILIARES +# =============================== +def _str_to_time(hhmm: str) -> dtime: + """Converte 'HH:MM' para datetime.time. Ex.: '08:30' -> time(8,30)""" + h, m = (hhmm or "00:00").split(":") + return dtime(int(h), int(m)) + + +def _is_within_window(janela: Dict[str, str]) -> bool: + """ + Verifica se horário atual está dentro da janela declarada. + Exemplo de janela: {"inicio":"08:00","fim":"18:00"} + """ + try: + agora = datetime.now().time() + t_ini = _str_to_time(janela.get("inicio", "00:00")) + t_fim = _str_to_time(janela.get("fim", "23:59")) + # Janela sem virada de dia + if t_ini <= t_fim: + return t_ini <= agora <= t_fim + # Janela com virada (ex.: 22:00 -> 06:00) + return (agora >= t_ini) or (agora <= t_fim) + except Exception: + # Se deu erro ao interpretar, não bloqueia por horário + return True + + +def _is_feature_enabled(flag_name: Optional[str]) -> bool: + """ + Verifica se uma feature está habilitada via variáveis de ambiente. + - Convenção: IOIRUN_FEATURE_=on|off|1|0 (case-insensitive) + """ + if not flag_name: + return True + raw = os.getenv(f"IOIRUN_FEATURE_{flag_name}".upper(), "on").strip().lower() + return raw in ("on", "1", "true", "yes", "y") + + +# =============================== +# REGRA CENTRAL DE VISIBILIDADE +# =============================== +def modulo_visivel_para_usuario( + mod_id: str, + MODULES: Dict[str, Dict], + *, + perfil: str, + usuario: Optional[str], + ambiente: str = "prod", # "prod" | "test" | "dev" +) -> bool: + """ + Determina se o módulo 'mod_id' deve aparecer para o usuário atual, + com base em regras opcionais no dict MODULES[mod_id]. + + Chaves opcionais no MODULES[mod_id]: + - "perfis": list[str] -> já padrão no seu código + - "somente_prod": bool + - "feature_flag": str + - "janela_horario": {"inicio":"HH:MM", "fim":"HH:MM"} + - "usuarios_permitidos": list[str] + - "usuarios_bloqueados": list[str] + - "manutencao": bool + """ + meta = MODULES.get(mod_id, {}) + + # 1) Perfis (regra base) + perfis_ok = meta.get("perfis", []) + if perfis_ok and perfil not in perfis_ok: + return False + + # 2) Ambiente + if meta.get("somente_prod", False) and ambiente != "prod": + return False + + # 3) Feature Flag opcional + if not _is_feature_enabled(meta.get("feature_flag")): + return False + + # 4) Janela de horário opcional + janela = meta.get("janela_horario") + if isinstance(janela, dict): + if not _is_within_window(janela): + return False + + # 5) Whitelist / Blacklist por usuário + if usuario: + whitelist = meta.get("usuarios_permitidos") or [] + blacklist = meta.get("usuarios_bloqueados") or [] + if whitelist and usuario not in whitelist: + return False + if blacklist and usuario in blacklist: + return False + + # 6) Manutenção + if meta.get("manutencao", False): + return False + + return True + + +# =============================== +# API PÚBLICA - MENU SIDEBAR +# =============================== +def obter_grupos_disponiveis( + MODULES: Dict[str, Dict], + *, + perfil: str, + usuario: Optional[str], + ambiente: str = "prod", + verificar_permissao: Optional[Callable[[str], bool]] = None +) -> List[str]: + """ + Retorna apenas os grupos (operações) que possuem pelo menos + 1 módulo visível para o usuário atual. + """ + grupos = set() + for mod_id, meta in MODULES.items(): + grupo = meta.get("grupo", "Outros") + # Permissão externa (a sua função atual) + if verificar_permissao and not verificar_permissao(mod_id): + continue + # Regras desta utils + if not modulo_visivel_para_usuario(mod_id, MODULES, perfil=perfil, usuario=usuario, ambiente=ambiente): + continue + grupos.add(grupo) + return sorted(grupos) + + +def obter_modulos_para_grupo( + MODULES: Dict[str, Dict], + grupo: str, + termo_busca: str, + *, + perfil: str, + usuario: Optional[str], + ambiente: str = "prod", + verificar_permissao: Optional[Callable[[str], bool]] = None +) -> List[Tuple[str, str]]: + """ + Retorna módulos (mod_id, label) visíveis para o usuário dentro do 'grupo', + já aplicando filtro de busca no label. + """ + opcoes: List[Tuple[str, str]] = [] + for mod_id, meta in MODULES.items(): + if meta.get("grupo", "Outros") != grupo: + continue + # Permissão externa (a sua função atual) + if verificar_permissao and not verificar_permissao(mod_id): + continue + # Regras desta utils + if not modulo_visivel_para_usuario(mod_id, MODULES, perfil=perfil, usuario=usuario, ambiente=ambiente): + continue + label = meta.get("label", mod_id) + # Filtro de busca (casefold para robustez) + if termo_busca and termo_busca not in label.casefold(): + continue + opcoes.append((mod_id, label)) + # Ordena pelo label exibido + opcoes.sort(key=lambda x: x[1].casefold()) + return opcoes diff --git a/utils_permissoes.py b/utils_permissoes.py new file mode 100644 index 0000000000000000000000000000000000000000..b792df86fb6cd789c7c052c709b58d5c49dba9e5 --- /dev/null +++ b/utils_permissoes.py @@ -0,0 +1,116 @@ + +# Utils_permissoes.py +import streamlit as st + +# ===================================================== +# 🔐 MAPA DE PERMISSÕES POR PERFIL +# ===================================================== +PERMISSOES = { + "admin": [ + "formulario", + "consulta", + "relatorio", + "quiz", + "ranking", + "quiz_admin", + "videos", + "usuarios", + "administracao", + "auditoria", + "importacao", + "calendario", + "auditoria_cleanup", + "jogos", + "temporario", + "db_admin", + "db_monitor", + "operacao", + "db_export_import", + "produtividade_especialista", + "resposta", + "outlook_relatorio", + "repositorio_load", + "rnc", + "rnc_listagem", + "rnc_relatorio", + "sugestoes_ioirun", + "repo_rnc", + "recebimento" + + ], + "usuario": [ + "administracao", + "formulario", + "consulta", + "quiz", + "ranking", + "videos", + "importacao", + "calendario", + "relatorio", + "produtividade_especialista", + "resposta", + "outlook_relatorio", + "repositorio_load", + "rnc", + "rnc_listagem", + "rnc_relatorio", + "sugestoes_ioirun", + "repo_rnc", + "recebimento" + ], + "consulta": [ + "consulta", + "ranking", + "videos", + "calendario", + "relatorio", + "resposta", + "repositorio_load", + "rnc_listagem", + "rnc_relatorio", + "sugestoes_ioirun" + ], +} + + +# ===================================================== +# ✅ VERIFICAÇÃO DE PERMISSÃO +# ===================================================== +def verificar_permissao(modulo_id: str = None, *, perfil: str = None, + modulo_key: str = None, usuario: str = None, + ambiente: str = None) -> bool: + """ + Verifica se o perfil do usuário logado + possui acesso ao módulo informado. + + ⚙️ Compatível com dois modos de chamada: + 1) verificar_permissao("consulta") + 2) verificar_permissao(perfil="usuario", modulo_key="consulta", usuario="rodrigo", ambiente="prod") + + - 'perfil' (se não informado) será lido de st.session_state["perfil"] + - 'modulo_key' (se não informado) recai para 'modulo_id' posicional + - 'usuario' e 'ambiente' são ignorados aqui, mas aceitos para compatibilidade + com utilitários que injetam esses parâmetros (ex.: utils_operacao). + """ + + # 🔁 Back-compat: aceita tanto modulo_id posicional quanto modulo_key nomeado + target_mod = modulo_key or modulo_id + if not target_mod: + return False # sem módulo alvo, não há o que validar + + # Obter perfil do usuário: prioriza argumento nomeado; se ausente, usa sessão + perf = perfil or st.session_state.get("perfil") + if not perf: + return False + + # --- opcional: normalização defensiva (ative se necessário) --- + # perf = str(perf).strip().lower() + # target_mod = str(target_mod).strip().lower() + + # Lista de módulos permitidos para o perfil + allowed = PERMISSOES.get(perf, []) + + # ✅ Retorna True se o módulo estiver na lista do perfil + return target_mod in allowed + diff --git a/utils_quiz.py b/utils_quiz.py new file mode 100644 index 0000000000000000000000000000000000000000..115d20b10ea3d707206eb65e46668d4015f05deb --- /dev/null +++ b/utils_quiz.py @@ -0,0 +1,21 @@ +from banco import SessionLocal +from models import QuizPontuacao +from sqlalchemy import func +from datetime import date + + +def usuario_ja_fez_quiz_hoje(usuario): + db = SessionLocal() + try: + hoje = date.today() + quiz = ( + db.query(QuizPontuacao) + .filter( + QuizPontuacao.usuario == usuario, + func.date(QuizPontuacao.data) == hoje + ) + .first() + ) + return quiz is not None + finally: + db.close() diff --git a/utils_seguranca.py b/utils_seguranca.py new file mode 100644 index 0000000000000000000000000000000000000000..e93429c5fa43f053cc019aff140b22b75ca579af --- /dev/null +++ b/utils_seguranca.py @@ -0,0 +1,14 @@ +import bcrypt + + +def gerar_hash_senha(senha: str) -> str: + senha_bytes = senha.encode("utf-8") + hash_bytes = bcrypt.hashpw(senha_bytes, bcrypt.gensalt()) + return hash_bytes.decode("utf-8") + + +def verificar_senha(senha_digitada: str, senha_hash: str) -> bool: + return bcrypt.checkpw( + senha_digitada.encode("utf-8"), + senha_hash.encode("utf-8") + ) diff --git a/verificar_banco.py b/verificar_banco.py new file mode 100644 index 0000000000000000000000000000000000000000..908a5ddf2d7386219581293590f25e9225d80710 --- /dev/null +++ b/verificar_banco.py @@ -0,0 +1,18 @@ +import sqlite3 +import os + +db_path = r"C:\Users\rodrigo.silva\OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA\Load\LoadApp\load.db" + +print("📂 Banco encontrado?", os.path.exists(db_path)) +print("📂 Caminho:", db_path) + +conn = sqlite3.connect(db_path) +cursor = conn.cursor() + +print("\n📋 COLUNAS DA TABELA equipamentos:\n") + +cursor.execute("PRAGMA table_info(equipamentos)") +for col in cursor.fetchall(): + print(col) + +conn.close() diff --git a/videos.py b/videos.py new file mode 100644 index 0000000000000000000000000000000000000000..6e2c1e63be5d29440738e1405ba7cc24a1bdaecc --- /dev/null +++ b/videos.py @@ -0,0 +1,230 @@ + +import streamlit as st +import streamlit.components.v1 as components +from datetime import datetime +from banco import SessionLocal +from models import Video, VideoCategoria, LogAcesso +import re + +# ===================================================== +# NORMALIZA URL YOUTUBE + IDENTIFICA SHORT +# ===================================================== +def youtube_embed(url): + if "/shorts/" in url: + video_id = url.split("/shorts/")[-1].split("?")[0] + return f"https://www.youtube.com/embed/{video_id}", "short" + + match = re.search(r"v=([^&]+)", url) + if match: + return f"https://www.youtube.com/embed/{match.group(1)}", "normal" + + if "youtu.be/" in url: + video_id = url.split("youtu.be/")[-1].split("?")[0] + return f"https://www.youtube.com/embed/{video_id}", "normal" + + return None, None + +# ===================================================== +# AUDITORIA +# ===================================================== +def registrar_auditoria(video_id): + db = SessionLocal() + try: + log = LogAcesso( + usuario=st.session_state.get("usuario", "desconhecido"), + acao="ASSISTIU_VIDEO", + tabela="videos", + registro_id=video_id, + data_hora=datetime.now() + ) + db.add(log) + db.commit() + finally: + db.close() + +# ===================================================== +# APP PRINCIPAL +# ===================================================== +def main(): + st.title("▶️ Treinamentos em Vídeo") + + perfil = st.session_state.get("perfil") + db = SessionLocal() + + # ===================================================== + # 🔒 ADMIN — CADASTRO + # ===================================================== + if perfil == "admin": + st.subheader("🔧 Administração de Vídeos") + + # ✅ Cadastro de Categoria + with st.expander("➕ Cadastrar Categoria"): + nome_categoria = st.text_input("Nome da Categoria") + + if st.button("Salvar Categoria"): + if nome_categoria: + existe = db.query(VideoCategoria).filter_by(nome=nome_categoria).first() + if not existe: + db.add(VideoCategoria(nome=nome_categoria)) + db.commit() + st.success("Categoria cadastrada") + st.rerun() + else: + st.warning("Categoria já existe") + + # ✅ Cadastro de Vídeo + with st.expander("➕ Cadastrar Vídeo"): + categorias = db.query(VideoCategoria).filter_by(ativo=True).all() + mapa = {c.nome: c.id for c in categorias} + + titulo = st.text_input("Título do Vídeo") + descricao = st.text_area("Descrição") + url = st.text_input("URL do YouTube (vídeo ou short)") + categoria = st.selectbox("Categoria", [""] + list(mapa.keys())) + + if st.button("Salvar Vídeo"): + embed, _ = youtube_embed(url) + + if not titulo or not url or not categoria: + st.warning("Preencha todos os campos obrigatórios") + elif not embed: + st.error("URL do YouTube inválida") + else: + db.add(Video( + titulo=titulo, + descricao=descricao, + url=url, + categoria_id=mapa[categoria] + )) + db.commit() + st.success("Vídeo cadastrado com sucesso") + st.rerun() + + st.divider() + + # ✅ Listagem para Editar/Excluir + st.subheader("📋 Gerenciar Vídeos") + videos_admin = db.query(Video).filter(Video.ativo == True).all() + + for video in videos_admin: + with st.expander(f"▶ {video.titulo}"): + st.caption(video.descricao or "Sem descrição") + st.write(f"Categoria ID: {video.categoria_id}") + st.write(f"URL: {video.url}") + + col1, col2 = st.columns([1, 1]) + if col1.button("✏️ Editar", key=f"edit_{video.id}"): + st.session_state["edit_video_id"] = video.id + st.rerun() + + if col2.button("🗑️ Excluir", key=f"delete_{video.id}"): + st.session_state["delete_video_id"] = video.id + st.rerun() + + # ✅ Confirmação de exclusão + if st.session_state.get("delete_video_id"): + video_del = db.query(Video).get(st.session_state["delete_video_id"]) + st.error(f"⚠️ Tem certeza que deseja excluir o vídeo: **{video_del.titulo}**?") + col_a, col_b = st.columns([1, 1]) + if col_a.button("✅ Confirmar Exclusão"): + db.delete(video_del) + db.commit() + st.success("Vídeo excluído com sucesso!") + st.session_state["delete_video_id"] = None + st.rerun() + if col_b.button("❌ Cancelar"): + st.session_state["delete_video_id"] = None + st.rerun() + + # ✅ Formulário de edição + if st.session_state.get("edit_video_id"): + video_edit = db.query(Video).get(st.session_state["edit_video_id"]) + st.subheader(f"✏️ Editando: {video_edit.titulo}") + + novo_titulo = st.text_input("Título", value=video_edit.titulo) + nova_descricao = st.text_area("Descrição", value=video_edit.descricao) + nova_url = st.text_input("URL do YouTube", value=video_edit.url) + + categorias = db.query(VideoCategoria).filter_by(ativo=True).all() + mapa = {c.nome: c.id for c in categorias} + nova_categoria = st.selectbox("Categoria", list(mapa.keys()), index=list(mapa.values()).index(video_edit.categoria_id)) + + col_save, col_cancel = st.columns([1, 1]) + if col_save.button("💾 Salvar Alterações"): + embed, _ = youtube_embed(nova_url) + if not embed: + st.error("URL inválida") + else: + video_edit.titulo = novo_titulo + video_edit.descricao = nova_descricao + video_edit.url = nova_url + video_edit.categoria_id = mapa[nova_categoria] + db.commit() + st.success("Vídeo atualizado com sucesso!") + st.session_state["edit_video_id"] = None + st.rerun() + + if col_cancel.button("❌ Cancelar Edição"): + st.session_state["edit_video_id"] = None + st.rerun() + + # ===================================================== + # 🎬 EXIBIÇÃO — 3 VÍDEOS POR LINHA + # ===================================================== + st.subheader("🎬 Vídeos Disponíveis") + + categorias = db.query(VideoCategoria).filter_by(ativo=True).all() + + for categoria in categorias: + st.markdown(f"## 📂 {categoria.nome}") + + videos = ( + db.query(Video) + .filter(Video.categoria_id == categoria.id, Video.ativo == True) + .all() + ) + + if not videos: + st.caption("Nenhum vídeo nesta categoria.") + continue + + # 🔁 3 vídeos por linha + for i in range(0, len(videos), 3): + cols = st.columns(3) + + for col, video in zip(cols, videos[i:i+3]): + with col: + st.write(f"### ▶ {video.titulo}") + if video.descricao: + st.caption(video.descricao) + + embed, tipo = youtube_embed(video.url) + + if st.button("Assistir", key=f"video_{video.id}"): + registrar_auditoria(video.id) + + if tipo == "short": + width, height = 260, 460 + else: + width, height = 360, 203 + + components.html( + f""" + + """, + height=height + 20 + ) + + db.close() + +if __name__ == "__main__": + main() + + diff --git a/videos_admin.py b/videos_admin.py new file mode 100644 index 0000000000000000000000000000000000000000..8505a4a6fefc6a859465ff104b028bb15413396f --- /dev/null +++ b/videos_admin.py @@ -0,0 +1,59 @@ +import streamlit as st +from banco import SessionLocal +from models import Video, VideoCategoria +from utils_auditoria import registrar_log + +def main(): + st.title("🛠️ Administração de Vídeos") + + db = SessionLocal() + + categorias = db.query(VideoCategoria).filter(VideoCategoria.ativo == True).all() + categorias_nomes = [""] + [c.nome for c in categorias] + ["➕ Nova categoria"] + + with st.form("form_video"): + titulo = st.text_input("Título do vídeo *") + descricao = st.text_area("Descrição") + url = st.text_input("Link do YouTube *") + + categoria_escolhida = st.selectbox("Categoria", categorias_nomes) + + if categoria_escolhida == "➕ Nova categoria": + nova_categoria = st.text_input("Nome da nova categoria") + else: + nova_categoria = None + + salvar = st.form_submit_button("💾 Salvar Vídeo") + + if salvar: + if not titulo or not url: + st.error("Título e URL são obrigatórios.") + return + + if nova_categoria: + categoria = VideoCategoria(nome=nova_categoria) + db.add(categoria) + db.commit() + else: + categoria = db.query(VideoCategoria).filter_by(nome=categoria_escolhida).first() + + video = Video( + titulo=titulo, + descricao=descricao, + url=url, + categoria_id=categoria.id if categoria else None + ) + + db.add(video) + db.commit() + + registrar_log( + usuario=st.session_state.get("usuario"), + acao=f"CADASTROU VÍDEO: {titulo}", + tabela="videos", + registro_id=video.id + ) + + st.success("✅ Vídeo cadastrado com sucesso!") + + db.close()