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"""
+
+
+ """,
+ 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"""
+
+
+
+""",
+ 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_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}
+
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()