This view is limited to 50 files because it contains too many changes.  See the raw diff here.
.audit_report.json ADDED
@@ -0,0 +1,219 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "duplicate_keys": {},
3
+ "widgets_without_key": {
4
+ ".\\app.py": {
5
+ "button_no_key": [
6
+ 445
7
+ ]
8
+ },
9
+ ".\\app_outlook.py": {
10
+ "download_no_key": [
11
+ 24,
12
+ 36,
13
+ 82
14
+ ]
15
+ },
16
+ ".\\auditoria.py": {
17
+ "download_no_key": [
18
+ 91
19
+ ]
20
+ },
21
+ ".\\auditoria_cleanup.py": {
22
+ "button_no_key": [
23
+ 65
24
+ ]
25
+ },
26
+ ".\\consulta.py": {
27
+ "download_no_key": [
28
+ 171
29
+ ]
30
+ },
31
+ ".\\db_admin.py": {
32
+ "button_no_key": [
33
+ 213,
34
+ 241,
35
+ 263,
36
+ 316,
37
+ 360
38
+ ]
39
+ },
40
+ ".\\db_export_import.py": {
41
+ "button_no_key": [
42
+ 273,
43
+ 285,
44
+ 301,
45
+ 320,
46
+ 333
47
+ ],
48
+ "download_no_key": [
49
+ 277,
50
+ 289,
51
+ 305
52
+ ]
53
+ },
54
+ ".\\db_monitor.py": {
55
+ "button_no_key": [
56
+ 261,
57
+ 265
58
+ ]
59
+ },
60
+ ".\\db_tools.py": {
61
+ "button_no_key": [
62
+ 57
63
+ ]
64
+ },
65
+ ".\\importar_excel.py": {
66
+ "download_no_key": [
67
+ 77
68
+ ]
69
+ },
70
+ ".\\jogos.py": {
71
+ "button_no_key": [
72
+ 104,
73
+ 256,
74
+ 356
75
+ ]
76
+ },
77
+ ".\\login.py": {
78
+ "button_no_key": [
79
+ 83
80
+ ]
81
+ },
82
+ ".\\operacao.py": {
83
+ "button_no_key": [
84
+ 1444,
85
+ 1452,
86
+ 1471,
87
+ 1487
88
+ ],
89
+ "download_no_key": [
90
+ 1543,
91
+ 1547
92
+ ]
93
+ },
94
+ ".\\outlook_relatorio.py": {
95
+ "download_no_key": [
96
+ 30,
97
+ 42,
98
+ 79
99
+ ]
100
+ },
101
+ ".\\Produtividade_Especialista.py": {
102
+ "button_no_key": [
103
+ 52
104
+ ],
105
+ "download_no_key": [
106
+ 506,
107
+ 518
108
+ ]
109
+ },
110
+ ".\\quiz.py": {
111
+ "button_no_key": [
112
+ 107
113
+ ]
114
+ },
115
+ ".\\quiz_admin.py": {
116
+ "button_no_key": [
117
+ 57
118
+ ]
119
+ },
120
+ ".\\ranking.py": {
121
+ "download_no_key": [
122
+ 109
123
+ ]
124
+ },
125
+ ".\\repositorio_load.py": {
126
+ "download_no_key": [
127
+ 251,
128
+ 344
129
+ ]
130
+ },
131
+ ".\\videos.py": {
132
+ "button_no_key": [
133
+ 65,
134
+ 85
135
+ ]
136
+ }
137
+ },
138
+ "missing_imports_in_app": [],
139
+ "routing_vs_modules": {
140
+ "routes_without_modules_entry": [],
141
+ "modules_entry_without_route": [
142
+ "administracao",
143
+ "auditoria",
144
+ "auditoria_cleanup",
145
+ "backload_consulta",
146
+ "calendario",
147
+ "calendario_mensal",
148
+ "consulta",
149
+ "db_admin",
150
+ "db_export_import",
151
+ "db_monitor",
152
+ "formulario",
153
+ "importacao",
154
+ "indicadores",
155
+ "jogos",
156
+ "operacao",
157
+ "outlook_relatorio",
158
+ "produtividade_especialista",
159
+ "quiz",
160
+ "quiz_admin",
161
+ "ranking",
162
+ "relatorio",
163
+ "repositorio_load",
164
+ "resposta",
165
+ "sugestoes_ioirun",
166
+ "terceiros_gestao",
167
+ "usuarios",
168
+ "videos"
169
+ ]
170
+ },
171
+ "module_files_missing": [],
172
+ "modules_without_main": [],
173
+ "unused_imports": {
174
+ ".\\auto_capture.py": [
175
+ "TimeoutError"
176
+ ],
177
+ ".\\calendario_mensal.py": [
178
+ "formatar_data_br"
179
+ ],
180
+ ".\\db_admin.py": [
181
+ "SessionLocal"
182
+ ],
183
+ ".\\db_export_import.py": [
184
+ "SessionLocal"
185
+ ],
186
+ ".\\db_monitor.py": [
187
+ "time",
188
+ "SessionLocal",
189
+ "verificar_permissao"
190
+ ],
191
+ ".\\env_audit.py": [
192
+ "Tuple"
193
+ ],
194
+ ".\\init_db.py": [
195
+ "models"
196
+ ],
197
+ ".\\modules_map.py": [
198
+ "calendario",
199
+ "calendario_mensal"
200
+ ],
201
+ ".\\utils_auditoria.py": [
202
+ "db_info"
203
+ ],
204
+ ".\\utils_campos.py": [
205
+ "Equipamento"
206
+ ],
207
+ ".\\utils_datas.py": [
208
+ "date"
209
+ ],
210
+ ".\\utils_lembretes.py": [
211
+ "date"
212
+ ],
213
+ ".\\utils_operacao.py": [
214
+ "annotations",
215
+ "st"
216
+ ]
217
+ },
218
+ "import_cycles": []
219
+ }
.env ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # --- Mayasuite API (Operação) ---
3
+ OP_ACCESS_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJmcmVzaCI6ZmFsc2UsImlhdCI6MTc2ODQ4NjI5NywianRpIjoiNTQ1NjdkYmUtZGUxZi00ZDAxLTkzYzktZGRiYzk4MGJmYWNlIiwidHlwZSI6ImFjY2VzcyIsInN1YiI6IjgzNDAxM2QzLWJhNTMtNDQ1MC1hZmJlLTc4ODZhZjQ5MjJiNCIsIm5iZiI6MTc2ODQ4NjI5NywiY3NyZiI6IjQ3OGNjM2RiLTU5ZGItNDU5NS04ZjdjLWQzM2RhMDAzMjZhMCIsImV4cCI6MTc2ODUyOTQ5N30.R3Bi6c9uxjv8ehvT6JqIshgQqiTJIP8Lm4XlmY-bStg
4
+ # Alternativas (opcionais, usadas só se a primária falhar)
5
+ OP_LOGIN_EMAIL_ALT=api@armmatriz.com.br
6
+ OP_LOGIN_PASSWORD_ALT=Arm@2025
7
+
8
+ # Ativa logs de corpo de erro (apenas DIAGNÓSTICO TEMPORÁRIO)
9
+ OP_LOGIN_DEBUG=true
10
+ # Configurações de requisição
11
+ OP_READ_TIMEOUT=60 # p.ex., 60s (alguns endpoints demoram mais)
12
+ OP_RATE_DELAY_SEC=0.5 # atraso menor entre páginas
13
+ OP_MAX_PAGES=1 # padrão apenas 1 página (você controla na UI)
14
+ OP_MAX_RETRIES_5XX=3 # menos tentativas para 5xx
15
+ OP_5XX_BACKOFF_BASE=2 # backoff mais curto
16
+ OP_RETRY_TIMEOUT_TOTAL=90 # timeout total menor para retries
17
+ # --- Fim Mayasuite API (Operação) ---
18
+
19
+
20
+ # ================================
21
+ # 🔀 Bancos (Multi‑ambiente SQLite)
22
+ # ================================
23
+ # Utilize estes URLs caso deseje ler os caminhos pelo .env.
24
+ # Para ativar no db_router.py, DESCOMENTE o bloco de dotenv nele.
25
+ DB1_PROD_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load.db
26
+ DB2_TEST_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load_teste.db
27
+ DB3_TREINAMENTO_URL=sqlite:///C:/Users/rodrigo.silva/OneDrive - ARM ARMAZENS GERAIS & LOGISTICA LTDA/Load/LoadApp/Load_treinamento.db
28
+
29
+ # (Opcional) rótulos amigáveis por ambiente (se quiser ler via .env)
30
+ DB1_LABEL=Banco 1 (📗 Produção)
31
+ DB2_LABEL=Banco 2 (📕 Teste)
32
+ DB3_LABEL=Banco 3 (📘 Treinamento)
33
+
34
+
35
+ # ==================================
36
+ # 🤖 Automação de captura/apresentação
37
+ # ==================================
38
+ # Usado pelo script auto_capture.py (Playwright + python-pptx)
39
+ APP_URL=http://localhost:8501
40
+
41
+ # Usuário/senha para login automático (recomendado perfil admin em Teste/Treinamento)
42
+ LOGIN_USER=admin
43
+ LOGIN_PASS=admin123
44
+
45
+ # Ambiente alvo para captura: prod | test | treinamento
46
+ BANK_CHOICE=prod
47
+
48
+ # Saídas de captura e apresentação
49
+ SCREEN_DIR=./screenshots
50
+ OUTPUT_PPTX=./demo_funcionalidades.pptx
51
+
52
+ # (Opcional) parâmetros da captura
53
+ AUTOCAPTURE_HEADLESS=false # true = sem abrir janela; false = visível
54
+ AUTOCAPTURE_VIEWPORT_W=1440
55
+ AUTOCAPTURE_VIEWPORT_H=900
56
+
57
+ # (Opcional) pular quiz durante captura (se seu login exigir quiz)
58
+ AUTOCAPTURE_SKIP_QUIZ=true
59
+
60
+
61
+ # ==========================
62
+ # 🧰 Monitor/Backup do banco
63
+ # ==========================
64
+ # Diretório padrão de backups (db_monitor.py)
65
+ BACKUP_DIR=./backups
66
+ BACKUP_RETAIN=10 # manter N arquivos mais recentes
67
+ BACKUP_FREQ_DAYS=7 # frequência "prevista" em dias
68
+
69
+ # (Opcional) mostrar URL do engine na sidebar (se usar no app.py)
70
+ SHOW_ENGINE_URL_IN_SIDEBAR=true
71
+
72
+
73
+ # ==========================
74
+ # 🔧 Streamlit (opcional)
75
+ # ==========================
76
+ # STREAMLIT_SERVER_ADDRESS=0.0.0.0
77
+ # STREAMLIT_SERVER_PORT=8501
78
+ # STREAMLIT_BROWSER_GATHER_USAGE_STATS=false
79
+ # STREAMLIT_THEME_BASE="light"
.gitattributes CHANGED
@@ -32,3 +32,5 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
32
  *.zip filter=lfs diff=lfs merge=lfs -text
33
  *.zst filter=lfs diff=lfs merge=lfs -text
34
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
32
  *.zip filter=lfs diff=lfs merge=lfs -text
33
  *.zst filter=lfs diff=lfs merge=lfs -text
34
  *tfevents* filter=lfs diff=lfs merge=lfs -text
35
+ Load.db filter=lfs diff=lfs merge=lfs -text
36
+ Load.db.bak filter=lfs diff=lfs merge=lfs -text
.gitignore CHANGED
@@ -1,132 +1,42 @@
1
- # Byte-compiled / optimized / DLL files
2
- __pycache__/
3
- *.py[cod]
4
- *$py.class
5
- # *.html
6
- private/
7
- .vscode/
8
-
9
- # C extensions
10
- *.so
11
-
12
- # Distribution / packaging
13
- .Python
14
- build/
15
- develop-eggs/
16
- dist/
17
- downloads/
18
- eggs/
19
- .eggs/
20
- lib/
21
- lib64/
22
- parts/
23
- sdist/
24
- var/
25
- wheels/
26
- pip-wheel-metadata/
27
- share/python-wheels/
28
- *.egg-info/
29
- .installed.cfg
30
- *.egg
31
- MANIFEST
32
-
33
- # PyInstaller
34
- # Usually these files are written by a python script from a template
35
- # before PyInstaller builds the exe, so as to inject date/other infos into it.
36
- *.manifest
37
- *.spec
38
-
39
- # Installer logs
40
- pip-log.txt
41
- pip-delete-this-directory.txt
42
-
43
- # Unit test / coverage reports
44
- htmlcov/
45
- .tox/
46
- .nox/
47
- .coverage
48
- .coverage.*
49
- .cache
50
- nosetests.xml
51
- coverage.xml
52
- *.cover
53
- *.py,cover
54
- .hypothesis/
55
- .pytest_cache/
56
-
57
- # Translations
58
- *.mo
59
- *.pot
60
-
61
- # Django stuff:
62
- *.log
63
- local_settings.py
64
- db.sqlite3
65
- db.sqlite3-journal
66
-
67
- # Flask stuff:
68
- instance/
69
- .webassets-cache
70
-
71
- # Scrapy stuff:
72
- .scrapy
73
-
74
- # Sphinx documentation
75
- docs/_build/
76
-
77
- # PyBuilder
78
- target/
79
-
80
- # Jupyter Notebook
81
- .ipynb_checkpoints
82
-
83
- # IPython
84
- profile_default/
85
- ipython_config.py
86
-
87
- # pyenv
88
- .python-version
89
-
90
- # pipenv
91
- # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
92
- # However, in case of collaboration, if having platform-specific dependencies or dependencies
93
- # having no cross-platform support, pipenv may install dependencies that don't work, or not
94
- # install all needed dependencies.
95
- #Pipfile.lock
96
-
97
- # PEP 582; used by e.g. github.com/David-OConnor/pyflow
98
- __pypackages__/
99
-
100
- # Celery stuff
101
- celerybeat-schedule
102
- celerybeat.pid
103
-
104
- # SageMath parsed files
105
- *.sage.py
106
-
107
- # Environments
108
- .env
109
- .venv
110
- env/
111
- venv/
112
- ENV/
113
- env.bak/
114
- venv.bak/
115
-
116
- # Spyder project settings
117
- .spyderproject
118
- .spyproject
119
-
120
- # Rope project settings
121
- .ropeproject
122
-
123
- # mkdocs documentation
124
- /site
125
-
126
- # mypy
127
- .mypy_cache/
128
- .dmypy.json
129
- dmypy.json
130
-
131
- # Pyre type checker
132
- .pyre/
 
1
+ # ========================
2
+ # Python
3
+ # ========================
4
+ __pycache__/
5
+ *.pyc
6
+ *.pyo
7
+ *.pyd
8
+
9
+ # ========================
10
+ # Ambiente virtual
11
+ # ========================
12
+ venv/
13
+ env/
14
+ .venv/
15
+
16
+ # ========================
17
+ # Variáveis de ambiente
18
+ # ========================
19
+ .env
20
+
21
+ # ========================
22
+ # Banco de dados
23
+ # ========================
24
+ *.db
25
+ *.sqlite
26
+ *.sqlite3
27
+
28
+ # ========================
29
+ # Streamlit
30
+ # ========================
31
+ .streamlit/
32
+
33
+ # ========================
34
+ # Logs
35
+ # ========================
36
+ *.log
37
+
38
+ # ========================
39
+ # Sistema operacional
40
+ # ========================
41
+ .DS_Store
42
+ Thumbs.db
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
.txt ADDED
@@ -0,0 +1,2 @@
 
 
 
1
+ DATABASE_URL=postgresql://...
2
+ SENHA_ADMIN=admin123
20260129_add_po_alt_pn_lot_batch.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """add po_alt, pn, lot_batch to recebimento_registros
2
+
3
+ Revision ID: 8f7c3e5a9b21
4
+ Revises: <COLOQUE_AQUI_O_REVISION_ID_ANTERIOR>
5
+ Create Date: 2026-01-29 13:20:00.000000
6
+ """
7
+
8
+ from alembic import op
9
+ import sqlalchemy as sa
10
+
11
+ # Revisão atual e anterior
12
+ revision = '8f7c3e5a9b21'
13
+ down_revision = '<COLOQUE_AQUI_O_REVISION_ID_ANTERIOR>'
14
+ branch_labels = None
15
+ depends_on = None
16
+
17
+
18
+ def upgrade():
19
+ # Usamos batch_alter_table p/ compatibilidade (SQLite etc.)
20
+ with op.batch_alter_table('recebimento_registros', schema=None) as batch_op:
21
+ batch_op.add_column(sa.Column('po_alt', sa.String(length=60), nullable=True))
22
+ batch_op.add_column(sa.Column('pn', sa.String(length=120), nullable=True))
23
+ batch_op.add_column(sa.Column('lot_batch', sa.String(length=120), nullable=True))
24
+
25
+ # Se desejar índices (opcionais), descomente:
26
+ # batch_op.create_index('ix_receb_po_alt', ['po_alt'])
27
+ # batch_op.create_index('ix_receb_pn', ['pn'])
28
+ # batch_op.create_index('ix_receb_lot_batch', ['lot_batch'])
29
+
30
+
31
+ def downgrade():
32
+ with op.batch_alter_table('recebimento_registros', schema=None) as batch_op:
33
+ # Se criou índices acima, primeiro drope-os:
34
+ # batch_op.drop_index('ix_receb_lot_batch')
35
+ # batch_op.drop_index('ix_receb_pn')
36
+ # batch_op.drop_index('ix_receb_po_alt')
37
+
38
+ batch_op.drop_column('lot_batch')
39
+ batch_op.drop_column('pn')
40
+ batch_op.drop_column('po_alt')
Inbox_Admin.py ADDED
@@ -0,0 +1,197 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # pages/Inbox_Admin.py
3
+ # -*- coding: utf-8 -*-
4
+ import streamlit as st
5
+ from datetime import datetime
6
+ from sqlalchemy import func
7
+
8
+ # Model
9
+ from models import IOIRunSugestao
10
+
11
+ # (Opcional) auditoria
12
+ try:
13
+ from utils_auditoria import registrar_log
14
+ except Exception:
15
+ registrar_log = None
16
+
17
+ # ------------- CONFIG BÁSICA -------------
18
+ st.set_page_config(page_title="📬 Inbox Admin • IOI-RUN", layout="wide")
19
+
20
+ STATUS_PENDENTE = "pendente"
21
+ STATUS_RESPONDIDA = "respondida"
22
+
23
+
24
+ # ------------- Sessão de banco ciente do ambiente -------------
25
+ def _get_db_session():
26
+ """
27
+ Retorna uma sessão de banco consistente com o ambiente atual.
28
+ Tenta usar o db_router (se presente); senão, cai para SessionLocal().
29
+ """
30
+ try:
31
+ from db_router import get_session_for_current_db
32
+ return get_session_for_current_db()
33
+ except Exception:
34
+ pass
35
+ try:
36
+ from banco import SessionLocal
37
+ return SessionLocal()
38
+ except Exception as e:
39
+ st.error(f"Banco indisponível: {e}")
40
+ raise
41
+
42
+
43
+ def _debug_banco_caption():
44
+ """Mostra em qual banco estamos (Produção/Teste/Treinamento)."""
45
+ try:
46
+ from db_router import current_db_choice, bank_label
47
+ choice = current_db_choice()
48
+ label = bank_label(choice)
49
+ st.caption(f"🗄️ Banco ativo: **{label}**")
50
+ except Exception:
51
+ st.caption("🗄️ Banco ativo: **default**")
52
+
53
+
54
+ # ------------- Guarda de rota (somente admin) -------------
55
+ def _ensure_admin():
56
+ perfil = (st.session_state.get("perfil") or "").strip().lower()
57
+ if perfil != "admin":
58
+ st.error("Acesso negado. Esta página é restrita a administradores.")
59
+ st.stop()
60
+
61
+
62
+ # ------------- Página -------------
63
+ def main():
64
+ _ensure_admin()
65
+
66
+ st.title("📬 Caixa de Entrada • IOI‑RUN (Admin)")
67
+ st.caption("Responda sugestões dos usuários em uma página separada, sem interferência do app principal.")
68
+ _debug_banco_caption()
69
+
70
+ # Estados persistentes exclusivos desta página (prefixo 'adm_inbox_')
71
+ st.session_state.setdefault("adm_inbox_area", "todos")
72
+ st.session_state.setdefault("adm_inbox_status", STATUS_PENDENTE)
73
+ st.session_state.setdefault("adm_inbox_usuario", "")
74
+ st.session_state.setdefault("adm_inbox_nonce", 0)
75
+
76
+ AREAS = ["todos", "WMS", "FPSO", "UI/UX", "Relatórios", "Integrações", "Performance", "Segurança", "Outros"]
77
+ STATUS = [STATUS_PENDENTE, STATUS_RESPONDIDA, "todos"]
78
+
79
+ # ------------- Filtros -------------
80
+ col_f1, col_f2, col_f3, col_f4 = st.columns([1, 1, 1, 0.6])
81
+ col_f1.selectbox(
82
+ "Área/Tema",
83
+ AREAS,
84
+ key="adm_inbox_area",
85
+ index=AREAS.index(st.session_state["adm_inbox_area"]) if st.session_state["adm_inbox_area"] in AREAS else 0
86
+ )
87
+ col_f2.selectbox(
88
+ "Status",
89
+ STATUS,
90
+ key="adm_inbox_status",
91
+ index=STATUS.index(st.session_state["adm_inbox_status"]) if st.session_state["adm_inbox_status"] in STATUS else 0
92
+ )
93
+ col_f3.text_input(
94
+ "Filtrar por usuário (login exato)",
95
+ key="adm_inbox_usuario",
96
+ value=st.session_state["adm_inbox_usuario"]
97
+ )
98
+
99
+ if col_f4.button("🔄 Atualizar lista"):
100
+ st.session_state["adm_inbox_nonce"] += 1
101
+ st.rerun()
102
+
103
+ # ------------- Consulta -------------
104
+ db = _get_db_session()
105
+ try:
106
+ q = db.query(IOIRunSugestao)
107
+ if st.session_state["adm_inbox_area"] != "todos":
108
+ q = q.filter(IOIRunSugestao.area == st.session_state["adm_inbox_area"])
109
+ if st.session_state["adm_inbox_status"] != "todos":
110
+ q = q.filter(func.lower(IOIRunSugestao.status) == st.session_state["adm_inbox_status"])
111
+ if (st.session_state["adm_inbox_usuario"] or "").strip():
112
+ q = q.filter(IOIRunSugestao.usuario == (st.session_state["adm_inbox_usuario"] or "").strip())
113
+
114
+ sugestoes = q.order_by(IOIRunSugestao.data_envio.desc()).all()
115
+ except Exception as e:
116
+ st.error(f"Erro ao consultar sugestões: {e}")
117
+ sugestoes = []
118
+
119
+ # ------------- Lista / Edição -------------
120
+ if not sugestoes:
121
+ st.info("Nenhuma sugestão encontrada para os filtros aplicados.")
122
+ else:
123
+ for s in sugestoes:
124
+ dt_envio = s.data_envio.strftime("%d/%m/%Y %H:%M") if s.data_envio else "—"
125
+ titulo = f"📩 {dt_envio} — {s.usuario} — Status: {s.status or '—'}"
126
+ if s.area:
127
+ titulo += f" — Área: {s.area}"
128
+
129
+ with st.expander(titulo, expanded=False):
130
+ st.markdown("**Sugestão:**")
131
+ st.write(s.mensagem or "—")
132
+
133
+ with st.form(key=f"adm_inbox_form_{s.id}", clear_on_submit=False):
134
+ resposta_txt = st.text_area(
135
+ f"Responder ao usuário ({s.usuario}) — ID {s.id}",
136
+ value=s.resposta or "",
137
+ key=f"adm_inbox_resposta_{s.id}",
138
+ placeholder="Digite sua resposta para este usuário…",
139
+ height=140
140
+ )
141
+ col_a1, col_a2 = st.columns([1, 1])
142
+ enviar = col_a1.form_submit_button("📤 Enviar resposta")
143
+ pendenciar = col_a2.form_submit_button("⏳ Marcar como pendente")
144
+
145
+ if enviar:
146
+ try:
147
+ s.resposta = (resposta_txt or "").strip()
148
+ s.status = STATUS_RESPONDIDA if s.resposta else STATUS_PENDENTE
149
+ s.data_resposta = datetime.now() if s.resposta else None
150
+ s.responsavel = st.session_state.get("usuario")
151
+
152
+ db.add(s)
153
+ db.commit()
154
+
155
+ # Auditoria (opcional)
156
+ if registrar_log and s.resposta:
157
+ try:
158
+ registrar_log(
159
+ usuario=st.session_state.get("usuario"),
160
+ acao=f"Respondeu sugestão IOI‑RUN (ID {s.id}) para {s.usuario}",
161
+ tabela="ioirun_sugestao",
162
+ registro_id=s.id
163
+ )
164
+ except Exception:
165
+ pass
166
+
167
+ st.success("Resposta registrada com sucesso! (Agora em 'respondida')")
168
+ st.rerun()
169
+ except Exception as e:
170
+ db.rollback()
171
+ st.error(f"Erro ao salvar resposta: {e}")
172
+
173
+ if pendenciar:
174
+ try:
175
+ s.status = STATUS_PENDENTE
176
+ s.resposta = None
177
+ s.data_resposta = None
178
+ s.responsavel = None
179
+ db.add(s)
180
+ db.commit()
181
+ st.info("Sugestão marcada como pendente novamente.")
182
+ st.rerun()
183
+ except Exception as e:
184
+ db.rollback()
185
+ st.error(f"Erro ao alterar status: {e}")
186
+
187
+ st.markdown("---")
188
+ st.caption("Use o **menu lateral** para navegar para outros módulos.")
189
+
190
+ try:
191
+ db.close()
192
+ except Exception:
193
+ pass
194
+
195
+
196
+ if __name__ == "__main__":
197
+ main()
Info.txt ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ LoadApp/
2
+
3
+ ├── app.py # Arquivo principal
4
+ ├── login.py # Login
5
+ ├── administracao.py # Área admin
6
+ ├── formulario.py # Inclusão
7
+ ├── consulta.py # Consulta
8
+ ├── relatorios.py # Relatórios
9
+
10
+ ├── banco.py # Conexão com banco
11
+ ├── models.py # Modelos SQLAlchemy
12
+ ├── utils_fpso.py
13
+ ├── utils_permissoes.py
14
+
15
+ ├── assets/
16
+ │ └── logo.png # Logo do sistema
17
+
18
+ ├── requirements.txt
19
+ ├── .gitignore
20
+ └── README.md
Load.db ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:1b624b9d0c5160a67fb95de8a6d21ebe03389aa8029273d8bc86216e51eec470
3
+ size 9220096
Load.db.bak ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:40613ff0f3898cf9307261a0b2bc2ec4a393e8315395ef0e1c116c7d6f49bde9
3
+ size 1196032
Load.py ADDED
File without changes
Produtividade_Especialista.py ADDED
@@ -0,0 +1,778 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ import pandas as pd
5
+ from io import BytesIO
6
+ from banco import SessionLocal
7
+ from models import Equipamento
8
+
9
+ # Auto-refresh
10
+ from streamlit_autorefresh import st_autorefresh
11
+ from datetime import datetime, timedelta
12
+
13
+ # SQL util
14
+ from sqlalchemy import text
15
+
16
+ # ====== Gráficos: Altair (preferência) + fallback Matplotlib ======
17
+ ALT_AVAILABLE = True
18
+ try:
19
+ import altair as alt
20
+ try:
21
+ alt.data_transformers.disable_max_rows()
22
+ except Exception:
23
+ pass
24
+ except Exception:
25
+ ALT_AVAILABLE = False
26
+
27
+ import matplotlib
28
+ matplotlib.use("Agg")
29
+ import matplotlib.pyplot as plt
30
+
31
+ # NumPy para cálculos numéricos robustos
32
+ import numpy as np
33
+
34
+
35
+ # ===============================
36
+ # Fotos de Responsáveis — Helpers (DB)
37
+ # ===============================
38
+ def _ensure_foto_table(db) -> None:
39
+ """Cria a tabela responsavel_foto se não existir (SQLite/PostgreSQL/MySQL)."""
40
+ dialect = db.bind.dialect.name
41
+
42
+ if dialect == "sqlite":
43
+ sql = """
44
+ CREATE TABLE IF NOT EXISTS responsavel_foto (
45
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
46
+ tipo TEXT NOT NULL, -- 'especialista' | 'conferente'
47
+ nome TEXT NOT NULL,
48
+ imagem BLOB NOT NULL, -- bytes
49
+ mimetype TEXT,
50
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
51
+ UNIQUE (tipo, nome)
52
+ )
53
+ """
54
+ elif dialect in ("postgresql", "postgres"):
55
+ sql = """
56
+ CREATE TABLE IF NOT EXISTS responsavel_foto (
57
+ id SERIAL PRIMARY KEY,
58
+ tipo TEXT NOT NULL, -- 'especialista' | 'conferente'
59
+ nome TEXT NOT NULL,
60
+ imagem BYTEA NOT NULL, -- bytes
61
+ mimetype TEXT,
62
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
63
+ UNIQUE (tipo, nome)
64
+ )
65
+ """
66
+ else: # mysql/mariadb
67
+ sql = """
68
+ CREATE TABLE IF NOT EXISTS responsavel_foto (
69
+ id INT AUTO_INCREMENT PRIMARY KEY,
70
+ tipo VARCHAR(32) NOT NULL,
71
+ nome VARCHAR(255) NOT NULL,
72
+ imagem LONGBLOB NOT NULL,
73
+ mimetype VARCHAR(64),
74
+ updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
75
+ UNIQUE KEY uq_tipo_nome (tipo, nome)
76
+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
77
+ """
78
+ db.execute(text(sql))
79
+ db.commit()
80
+
81
+
82
+ def _get_foto(db, tipo: str, nome: str):
83
+ """Retorna (bytes_imagem, mimetype, updated_at) ou (None, None, None)."""
84
+ if not (tipo and nome):
85
+ return None, None, None
86
+ _ensure_foto_table(db)
87
+ row = db.execute(
88
+ text(
89
+ "SELECT imagem, mimetype, updated_at "
90
+ "FROM responsavel_foto WHERE tipo = :t AND nome = :n LIMIT 1"
91
+ ),
92
+ {"t": tipo, "n": nome},
93
+ ).fetchone()
94
+ if row:
95
+ return row[0], (row[1] or "image/jpeg"), row[2]
96
+ return None, None, None
97
+
98
+
99
+ def _set_foto(db, tipo: str, nome: str, content: bytes, mimetype: str) -> None:
100
+ """Upsert simples por (tipo, nome)."""
101
+ if not (tipo and nome and content):
102
+ return
103
+ _ensure_foto_table(db)
104
+ upd = db.execute(
105
+ text(
106
+ "UPDATE responsavel_foto "
107
+ "SET imagem=:img, mimetype=:mt, updated_at=CURRENT_TIMESTAMP "
108
+ "WHERE tipo=:t AND nome=:n"
109
+ ),
110
+ {"img": content, "mt": mimetype or "image/jpeg", "t": tipo, "n": nome},
111
+ )
112
+ if upd.rowcount == 0:
113
+ db.execute(
114
+ text(
115
+ "INSERT INTO responsavel_foto (tipo, nome, imagem, mimetype) "
116
+ "VALUES (:t, :n, :img, :mt)"
117
+ ),
118
+ {"t": tipo, "n": nome, "img": content, "mt": mimetype or "image/jpeg"},
119
+ )
120
+ db.commit()
121
+
122
+
123
+ def _del_foto(db, tipo: str, nome: str) -> None:
124
+ if not (tipo and nome):
125
+ return
126
+ _ensure_foto_table(db)
127
+ db.execute(text("DELETE FROM responsavel_foto WHERE tipo=:t AND nome=:n"), {"t": tipo, "n": nome})
128
+ db.commit()
129
+
130
+
131
+ # ===============================
132
+ # Estado
133
+ # ===============================
134
+ def limpar_estado_prod_esp():
135
+ """Remove do session_state qualquer dado do módulo Produtividade_Especialista."""
136
+ for key in list(st.session_state.keys()):
137
+ if key.startswith("prod_esp_"):
138
+ del st.session_state[key]
139
+
140
+
141
+ # ===============================
142
+ # UI – Gerenciar fotos de responsáveis
143
+ # ===============================
144
+ def _ui_fotos_responsaveis(df: pd.DataFrame):
145
+ """Bloco para cadastrar/atualizar/remover fotos de Especialistas e Conferentes."""
146
+ st.subheader("📸 Fotos dos Responsáveis")
147
+
148
+ especialistas = sorted([x for x in df["Especialista"].dropna().astype(str).unique() if x.strip()])
149
+ conferentes = sorted([x for x in df["Conferente"].dropna().astype(str).unique() if x.strip()])
150
+
151
+ tab_esp, tab_conf = st.tabs(["Especialista", "Conferente"])
152
+
153
+ # ---------- Especialista ----------
154
+ with tab_esp:
155
+ col_e1, col_e2 = st.columns([1, 2])
156
+ with col_e1:
157
+ nome_esp = st.selectbox("Especialista", options=["(selecione)"] + especialistas, index=0, key="prod_esp_foto_esp_sel")
158
+ file_esp = st.file_uploader(
159
+ "Carregar foto (PNG/JPG/JPEG/GIF/WEBP) — Especialista",
160
+ type=["png", "jpg", "jpeg", "gif", "webp"],
161
+ key="prod_esp_foto_esp_up"
162
+ )
163
+ salvar_esp = st.button("💾 Salvar/Atualizar foto (Especialista)", key="prod_esp_foto_esp_salvar")
164
+ remover_esp = st.button("🗑️ Remover foto (Especialista)", key="prod_esp_foto_esp_remover")
165
+
166
+ with col_e2:
167
+ db = SessionLocal()
168
+ try:
169
+ if nome_esp and nome_esp != "(selecione)":
170
+ img_bytes, mt, updt = _get_foto(db, "especialista", nome_esp)
171
+ if img_bytes:
172
+ st.caption(f"Foto atual de **{nome_esp}** (atualizada em {updt})")
173
+ st.image(img_bytes, caption=nome_esp, use_container_width=False, width=220)
174
+ else:
175
+ st.info("Nenhuma foto cadastrada para este Especialista.")
176
+ finally:
177
+ db.close()
178
+
179
+ if salvar_esp:
180
+ if not (nome_esp and nome_esp != "(selecione)"):
181
+ st.warning("Selecione um Especialista.")
182
+ elif not file_esp:
183
+ st.warning("Escolha um arquivo de imagem para enviar.")
184
+ else:
185
+ content = file_esp.read()
186
+ mt = file_esp.type or "image/jpeg"
187
+ db = SessionLocal()
188
+ try:
189
+ _set_foto(db, "especialista", nome_esp, content, mt)
190
+ st.success("Foto salva/atualizada com sucesso!")
191
+ st.rerun()
192
+ except Exception as e:
193
+ db.rollback()
194
+ st.error(f"Erro ao salvar foto: {e}")
195
+ finally:
196
+ db.close()
197
+
198
+ if remover_esp:
199
+ if not (nome_esp and nome_esp != "(selecione)"):
200
+ st.warning("Selecione um Especialista.")
201
+ else:
202
+ db = SessionLocal()
203
+ try:
204
+ _del_foto(db, "especialista", nome_esp)
205
+ st.info("Foto removida.")
206
+ st.rerun()
207
+ except Exception as e:
208
+ db.rollback()
209
+ st.error(f"Erro ao remover foto: {e}")
210
+ finally:
211
+ db.close()
212
+
213
+ # ---------- Conferente ----------
214
+ with tab_conf:
215
+ col_c1, col_c2 = st.columns([1, 2])
216
+ with col_c1:
217
+ nome_conf = st.selectbox("Conferente", options=["(selecione)"] + conferentes, index=0, key="prod_esp_foto_conf_sel")
218
+ file_conf = st.file_uploader(
219
+ "Carregar foto (PNG/JPG/JPEG/GIF/WEBP) — Conferente",
220
+ type=["png", "jpg", "jpeg", "gif", "webp"],
221
+ key="prod_esp_foto_conf_up"
222
+ )
223
+ salvar_conf = st.button("💾 Salvar/Atualizar foto (Conferente)", key="prod_esp_foto_conf_salvar")
224
+ remover_conf = st.button("🗑️ Remover foto (Conferente)", key="prod_esp_foto_conf_remover")
225
+
226
+ with col_c2:
227
+ db = SessionLocal()
228
+ try:
229
+ if nome_conf and nome_conf != "(selecione)":
230
+ img_bytes, mt, updt = _get_foto(db, "conferente", nome_conf)
231
+ if img_bytes:
232
+ st.caption(f"Foto atual de **{nome_conf}** (atualizada em {updt})")
233
+ st.image(img_bytes, caption=nome_conf, use_container_width=False, width=220)
234
+ else:
235
+ st.info("Nenhuma foto cadastrada para este Conferente.")
236
+ finally:
237
+ db.close()
238
+
239
+ if salvar_conf:
240
+ if not (nome_conf and nome_conf != "(selecione)"):
241
+ st.warning("Selecione um Conferente.")
242
+ elif not file_conf:
243
+ st.warning("Escolha um arquivo de imagem para enviar.")
244
+ else:
245
+ content = file_conf.read()
246
+ mt = file_conf.type or "image/jpeg"
247
+ db = SessionLocal()
248
+ try:
249
+ _set_foto(db, "conferente", nome_conf, content, mt)
250
+ st.success("Foto salva/atualizada com sucesso!")
251
+ st.rerun()
252
+ except Exception as e:
253
+ db.rollback()
254
+ st.error(f"Erro ao salvar foto: {e}")
255
+ finally:
256
+ db.close()
257
+
258
+ if remover_conf:
259
+ if not (nome_conf and nome_conf != "(selecione)"):
260
+ st.warning("Selecione um Conferente.")
261
+ else:
262
+ db = SessionLocal()
263
+ try:
264
+ _del_foto(db, "conferente", nome_conf)
265
+ st.info("Foto removida.")
266
+ st.rerun()
267
+ except Exception as e:
268
+ db.rollback()
269
+ st.error(f"Erro ao remover foto: {e}")
270
+ finally:
271
+ db.close()
272
+
273
+
274
+ # ===============================
275
+ # Mini-gráfico mensal (% acertos) — Helpers
276
+ # ===============================
277
+ def _normalize_responsaveis(df: pd.DataFrame) -> pd.DataFrame:
278
+ """Normaliza nomes (remove espaços/None) para evitar falhas de comparação."""
279
+ for col in ["Especialista", "Conferente"]:
280
+ df[col] = df[col].astype(str).fillna("").str.strip()
281
+ df[col] = df[col].replace({"None": ""})
282
+ return df
283
+
284
+ def _month_labels_last_n(n: int) -> pd.DataFrame:
285
+ """Retorna DataFrame com os últimos n meses e rótulos MES/AA, em ordem cronológica."""
286
+ base = pd.Timestamp(datetime.now().replace(day=1))
287
+ months = [base - pd.DateOffset(months=i) for i in range(n-1, -1, -1)]
288
+ return pd.DataFrame({
289
+ "YM": [pd.Period(m, freq="M") for m in months],
290
+ "mes": [m.strftime("%b/%y").upper() for m in months]
291
+ })
292
+
293
+ def _serie_pct_mensal(df: pd.DataFrame, resp_col: str, nome: str, months: int = 6) -> pd.DataFrame:
294
+ """
295
+ Série mensal (últimos 'months' meses) de % acertos (MROB) para um responsável.
296
+ Retorna DataFrame com ['mes', 'pct', 'MROB', 'ERROS'] (meses sem dados => 0).
297
+ Corrige dtype para evitar TypeError: Expected numeric dtype, got object instead.
298
+ """
299
+ if not (nome and resp_col in df.columns and "Data Coleta (dt)" in df.columns):
300
+ return pd.DataFrame(columns=["mes", "pct", "MROB", "ERROS"])
301
+
302
+ nome = str(nome).strip()
303
+ d = df[df[resp_col].astype(str).str.strip() == nome].copy()
304
+ d = d.dropna(subset=["Data Coleta (dt)"])
305
+
306
+ # Linha do tempo alvo (sempre haverá N meses)
307
+ base = _month_labels_last_n(months)
308
+
309
+ # Se não há dados, devolve zeros
310
+ if d.empty:
311
+ base["MROB"] = 0.0
312
+ base["ERROS"] = 0.0
313
+ base["pct"] = 0.0
314
+ return base[["mes", "pct", "MROB", "ERROS"]]
315
+
316
+ d["YM"] = d["Data Coleta (dt)"].dt.to_period("M")
317
+
318
+ g = (
319
+ d.groupby("YM", as_index=False)
320
+ .agg(MROB=("Linhas MROB", "sum"), ERROS=("Linhas Erros MROB", "sum"))
321
+ )
322
+
323
+ # Merge garante a linha do tempo completa — aqui o dtype pode virar 'object'
324
+ m = base.merge(g, on="YM", how="left")
325
+
326
+ # Coerção numérica robusta pós-merge (evita object -> round error)
327
+ m["MROB"] = pd.to_numeric(m["MROB"], errors="coerce").fillna(0).astype("float64")
328
+ m["ERROS"] = pd.to_numeric(m["ERROS"], errors="coerce").fillna(0).astype("float64")
329
+
330
+ # % acertos (evita divisão por zero, resultado sempre float)
331
+ m["pct"] = np.where(
332
+ m["MROB"] > 0,
333
+ ((m["MROB"] - m["ERROS"]) / m["MROB"]) * 100.0,
334
+ 0.0
335
+ )
336
+ m["pct"] = pd.to_numeric(m["pct"], errors="coerce").fillna(0).astype("float64").round(2)
337
+
338
+ # Seleciona e ordena colunas finais
339
+ out = m[["mes", "pct", "MROB", "ERROS"]].copy()
340
+
341
+ # Garantia de dtype correto (evita regressões futuras)
342
+ out["MROB"] = out["MROB"].astype("float64")
343
+ out["ERROS"] = out["ERROS"].astype("float64")
344
+ out["pct"] = out["pct"].astype("float64")
345
+
346
+ return out
347
+
348
+ 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"):
349
+ """
350
+ Renderiza mini‑gráfico compacto (% acertos | 0–100) com fallback:
351
+ 1) Altair (linha/barras + meta) 2) Matplotlib 3) Tabela
352
+ """
353
+ if df_m.empty:
354
+ st.caption("Sem dados mensais para o período/seleção atual.")
355
+ return
356
+
357
+ # 1) ALTair
358
+ if ALT_AVAILABLE:
359
+ try:
360
+ base = alt.Chart(df_m).encode(
361
+ x=alt.X("mes:N", title="Mês"),
362
+ y=alt.Y("pct:Q", title="% Acertos", scale=alt.Scale(domain=[0, 100])),
363
+ tooltip=[
364
+ alt.Tooltip("mes:N", title="Mês"),
365
+ alt.Tooltip("pct:Q", title="% Acertos (%)"),
366
+ alt.Tooltip("MROB:Q", title="MROB (Σ)"),
367
+ alt.Tooltip("ERROS:Q", title="Erros MROB (Σ)")
368
+ ]
369
+ )
370
+ chart = base.mark_line(point=True, interpolate="monotone", color="#0d6efd") if chart_type == "Linha" \
371
+ else base.mark_bar(size=18, color="#0d6efd")
372
+
373
+ final = chart.properties(width=260, height=150, title=titulo)
374
+
375
+ if show_meta:
376
+ meta_df = pd.DataFrame({"y": [meta]})
377
+ meta_rule = alt.Chart(meta_df).mark_rule(color="#16a34a", strokeDash=[6, 4]).encode(y="y:Q")
378
+ final = final + meta_rule
379
+
380
+ st.altair_chart(final, use_container_width=False)
381
+ return
382
+ except Exception as e:
383
+ st.info(f"Render ALTair indisponível, usando fallback (detalhe: {e})")
384
+
385
+ # 2) Matplotlib fallback
386
+ try:
387
+ fig, ax = plt.subplots(figsize=(3.2, 1.6), dpi=150)
388
+ x = list(range(len(df_m["mes"])))
389
+ if chart_type == "Linha":
390
+ ax.plot(x, df_m["pct"].values, marker="o", color="#0d6efd", linewidth=1.5)
391
+ else:
392
+ ax.bar(x, df_m["pct"].values, color="#0d6efd", width=0.6)
393
+ if show_meta:
394
+ ax.axhline(y=meta, color="#16a34a", linestyle="--", linewidth=1)
395
+
396
+ ax.set_ylim(0, 100)
397
+ ax.set_xticks(x)
398
+ ax.set_xticklabels(df_m["mes"].tolist(), rotation=0, fontsize=7)
399
+ ax.set_yticks([0, 20, 40, 60, 80, 100])
400
+ ax.set_title(titulo, fontsize=9)
401
+ ax.grid(alpha=0.15, axis="y")
402
+
403
+ plt.tight_layout()
404
+ st.pyplot(fig, use_container_width=False)
405
+ plt.close(fig)
406
+ return
407
+ except Exception as e:
408
+ st.warning(f"Não foi possível renderizar o mini‑gráfico (fallback MPL): {e}")
409
+
410
+ # 3) Último recurso
411
+ st.caption("Exibindo dados da série por impossibilidade de gráfico:")
412
+ st.dataframe(df_m, use_container_width=True)
413
+
414
+
415
+ # ===============================
416
+ # MAIN
417
+ # ===============================
418
+ def main():
419
+
420
+ # 🧹 LIMPA ESTADO AO ENTRAR
421
+ if not st.session_state.get("_prod_esp_inicializado"):
422
+ limpar_estado_prod_esp()
423
+ st.session_state["_prod_esp_inicializado"] = True
424
+
425
+ st.title("🏆 Produtividade por Especialista e Conferente")
426
+
427
+ # 🔧 CONTROLES NA SIDEBAR
428
+ with st.sidebar:
429
+ st.markdown("### 🔄 Atualização automática")
430
+ auto_on = st.checkbox("Ativar atualização automática", value=True, key="prod_esp_auto_on")
431
+ auto_interval_s = st.slider("Intervalo (segundos)", min_value=10, max_value=300, value=30, step=5, key="prod_esp_auto_int")
432
+
433
+ if "prod_esp_auto_int_effective" not in st.session_state:
434
+ st.session_state["prod_esp_auto_int_effective"] = auto_interval_s
435
+
436
+ if st.button("✅ Aplicar intervalo"):
437
+ st.session_state["prod_esp_auto_int_effective"] = auto_interval_s
438
+ st.success(f"Intervalo atualizado para {auto_interval_s}s")
439
+ st.rerun()
440
+
441
+ intervalo_efetivo = st.session_state.get("prod_esp_auto_int_effective", auto_interval_s)
442
+ st.caption(f"⏲️ Intervalo atual: **{intervalo_efetivo}s**")
443
+
444
+ st.markdown("---")
445
+ st.markdown("### 🎯 Metas e Série")
446
+ 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")
447
+ 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")
448
+ serie_meses = st.slider("Meses no mini‑gráfico", min_value=3, max_value=12, value=6, step=1, key="prod_esp_serie_meses")
449
+ tipo_grafico = st.selectbox("Tipo do mini‑gráfico", ["Linha", "Barras"], index=0, key="prod_esp_tipo_grafico")
450
+ linha_meta = st.checkbox("Mostrar linha de meta", value=True, key="prod_esp_show_meta")
451
+
452
+ st.markdown("---")
453
+ last_dt = st.session_state.get("prod_esp_last_update_dt")
454
+ if last_dt:
455
+ last_str = last_dt.strftime("%d/%m/%Y %H:%M:%S")
456
+ st.caption(f"🕒 Última atualização: **{last_str}**")
457
+ delta = datetime.now() - last_dt
458
+ if delta < timedelta(minutes=1):
459
+ ago_str = f"{delta.seconds}s"
460
+ elif delta < timedelta(hours=1):
461
+ mins = delta.seconds // 60
462
+ secs = delta.seconds % 60
463
+ ago_str = f"{mins}min {secs}s"
464
+ else:
465
+ hours = delta.seconds // 3600
466
+ mins = (delta.seconds % 3600) // 60
467
+ ago_str = f"{hours}h {mins}min"
468
+ st.caption(f"⏱️ Atualizado há **{ago_str}**")
469
+ if auto_on:
470
+ try:
471
+ nxt = (datetime.now() + timedelta(seconds=intervalo_efetivo)).strftime("%d/%m/%Y %H:%M:%S")
472
+ st.caption(f"🔁 Próximo refresh: **{nxt}**")
473
+ except Exception:
474
+ pass
475
+ else:
476
+ st.caption("🕒 Última atualização: **—**")
477
+
478
+ if auto_on:
479
+ st_autorefresh(interval=intervalo_efetivo * 1000, limit=None, key="prod_esp_autorefresh")
480
+
481
+ db = SessionLocal()
482
+ try:
483
+ registros = db.query(Equipamento).all()
484
+ st.session_state["prod_esp_last_update_dt"] = datetime.now()
485
+
486
+ if not registros:
487
+ st.info("Nenhum registro encontrado.")
488
+ return
489
+
490
+ # ========== BASE DF ==========
491
+ df = pd.DataFrame([{
492
+ "FPSO": getattr(r, "fpso", None),
493
+ "Data Coleta": getattr(r, "data_coleta", None),
494
+ "Modal": getattr(r, "modal", None),
495
+ "Especialista": getattr(r, "especialista", None),
496
+ "Conferente": getattr(r, "conferente", None),
497
+ "Linhas OSM": getattr(r, "linhas_osm", 0),
498
+ "Linhas MROB": getattr(r, "linhas_mrob", 0),
499
+ "Linhas Erros MROB": getattr(r, "linhas_erros_mrob", None),
500
+ "Linhas Erros (Genérico)": getattr(r, "linhas_erros", None),
501
+ } for r in registros])
502
+
503
+ # Conversão robusta de datas
504
+ df["Data Coleta (dt)"] = pd.to_datetime(df["Data Coleta"], errors="coerce", dayfirst=True)
505
+ if df["Data Coleta (dt)"].isna().all():
506
+ # tenta novamente sem dayfirst
507
+ df["Data Coleta (dt)"] = pd.to_datetime(df["Data Coleta"], errors="coerce", dayfirst=False)
508
+
509
+ # Tipos numéricos
510
+ for col in ["Linhas OSM", "Linhas MROB", "Linhas Erros MROB", "Linhas Erros (Genérico)"]:
511
+ if col in df.columns:
512
+ df[col] = pd.to_numeric(df[col], errors="coerce").fillna(0).astype("int64")
513
+
514
+ # Fallback de erros MROB
515
+ if ("Linhas Erros MROB" not in df.columns) or (df["Linhas Erros MROB"].sum() == 0 and df["Linhas Erros (Genérico)"].sum() > 0):
516
+ df["Linhas Erros MROB"] = df.get("Linhas Erros (Genérico)", pd.Series([0] * len(df)))
517
+
518
+ # Normaliza nomes
519
+ df = _normalize_responsaveis(df)
520
+
521
+ # ======== Fotos (cadastro/visualização) ========
522
+ _ui_fotos_responsaveis(df)
523
+
524
+ # ========== FILTROS ==========
525
+ st.subheader("🔎 Filtros")
526
+ col1, col2, col3 = st.columns(3)
527
+ with col1:
528
+ filtro_fpso = st.multiselect("FPSO", sorted(df["FPSO"].dropna().unique()), key="prod_esp_fpso")
529
+ with col2:
530
+ filtro_modal = st.multiselect("Modal", sorted(df["Modal"].dropna().unique()), key="prod_esp_modal")
531
+ with col3:
532
+ periodo = st.date_input("Período de Coleta", value=None, key="prod_esp_periodo")
533
+
534
+ df_filt = df.copy()
535
+ if filtro_fpso:
536
+ df_filt = df_filt[df_filt["FPSO"].isin(filtro_fpso)]
537
+ if filtro_modal:
538
+ df_filt = df_filt[df_filt["Modal"].isin(filtro_modal)]
539
+ if isinstance(periodo, (list, tuple)) and len(periodo) == 2:
540
+ data_inicio, data_fim = periodo
541
+ if pd.notna(data_inicio):
542
+ df_filt = df_filt[df_filt["Data Coleta (dt)"] >= pd.to_datetime(data_inicio)]
543
+ if pd.notna(data_fim):
544
+ df_filt = df_filt[df_filt["Data Coleta (dt)"] <= pd.to_datetime(data_fim) + pd.Timedelta(days=1) - pd.Timedelta(seconds=1)]
545
+
546
+ # ======== Mapeamentos por responsável ========
547
+ fpsos_por_especialista = (
548
+ df_filt.groupby("Especialista", dropna=False)["FPSO"]
549
+ .apply(lambda x: ", ".join(sorted(set(x.dropna()))))
550
+ .to_dict()
551
+ )
552
+ fpsos_por_conferente = (
553
+ df_filt.groupby("Conferente", dropna=False)["FPSO"]
554
+ .apply(lambda x: ", ".join(sorted(set(x.dropna()))))
555
+ .to_dict()
556
+ )
557
+
558
+ # ======== Agregações ========
559
+ grp_esp = (df_filt.groupby("Especialista", dropna=False)
560
+ .agg({"Linhas OSM":"sum","Linhas MROB":"sum","Linhas Erros MROB":"sum"})
561
+ .reset_index())
562
+ grp_esp["FPSO Responsável"] = grp_esp["Especialista"].map(lambda e: fpsos_por_especialista.get(e, ""))
563
+ grp_esp["Especialista (FPSO)"] = grp_esp.apply(
564
+ lambda r: f"{r['Especialista']} ({r['FPSO Responsável']})" if r["FPSO Responsável"] else str(r["Especialista"]), axis=1)
565
+ grp_esp["Total de Erros (MROB - Erros MROB)"] = (grp_esp["Linhas MROB"] - grp_esp["Linhas Erros MROB"]).clip(lower=0)
566
+
567
+ # ✅ Denominador numérico (float) para evitar dtype object
568
+ denom_mrob_esp = pd.to_numeric(grp_esp["Linhas MROB"], errors="coerce").replace(0, np.nan).astype("float64")
569
+ num_acertos_esp = pd.to_numeric(grp_esp["Linhas MROB"], errors="coerce") - pd.to_numeric(grp_esp["Linhas Erros MROB"], errors="coerce")
570
+ num_erros_esp = pd.to_numeric(grp_esp["Linhas Erros MROB"], errors="coerce")
571
+
572
+ grp_esp["% Acertos (MROB)"] = (num_acertos_esp / denom_mrob_esp * 100.0).round(2)
573
+ grp_esp["% Erros (MROB)"] = (num_erros_esp / denom_mrob_esp * 100.0).round(2)
574
+
575
+ grp_esp = grp_esp.sort_values(by="Linhas OSM", ascending=False)
576
+ grp_esp = grp_esp[[
577
+ "Especialista (FPSO)","Especialista","FPSO Responsável",
578
+ "Linhas OSM","Linhas MROB","Linhas Erros MROB",
579
+ "Total de Erros (MROB - Erros MROB)","% Acertos (MROB)","% Erros (MROB)"
580
+ ]]
581
+
582
+ grp_conf = (df_filt.groupby("Conferente", dropna=False)
583
+ .agg({"Linhas OSM":"sum","Linhas MROB":"sum","Linhas Erros MROB":"sum"})
584
+ .reset_index())
585
+ grp_conf["FPSO Responsável"] = grp_conf["Conferente"].map(lambda c: fpsos_por_conferente.get(c, ""))
586
+ grp_conf["Conferente (FPSO)"] = grp_conf.apply(
587
+ lambda r: f"{r['Conferente']} ({r['FPSO Responsável']})" if r["FPSO Responsável"] else str(r["Conferente"]), axis=1)
588
+ grp_conf["Total de Erros (MROB - Erros MROB)"] = (grp_conf["Linhas MROB"] - grp_conf["Linhas Erros MROB"]).clip(lower=0)
589
+
590
+ # ✅ Denominador numérico (float) para evitar dtype object
591
+ denom_mrob_conf = pd.to_numeric(grp_conf["Linhas MROB"], errors="coerce").replace(0, np.nan).astype("float64")
592
+ num_acertos_conf = pd.to_numeric(grp_conf["Linhas MROB"], errors="coerce") - pd.to_numeric(grp_conf["Linhas Erros MROB"], errors="coerce")
593
+ num_erros_conf = pd.to_numeric(grp_conf["Linhas Erros MROB"], errors="coerce")
594
+
595
+ grp_conf["% Acertos (MROB)"] = (num_acertos_conf / denom_mrob_conf * 100.0).round(2)
596
+ grp_conf["% Erros (MROB)"] = (num_erros_conf / denom_mrob_conf * 100.0).round(2)
597
+
598
+ grp_conf = grp_conf.sort_values(by="Linhas OSM", ascending=False)
599
+ grp_conf = grp_conf[[
600
+ "Conferente (FPSO)","Conferente","FPSO Responsável",
601
+ "Linhas OSM","Linhas MROB","Linhas Erros MROB",
602
+ "Total de Erros (MROB - Erros MROB)","% Acertos (MROB)","% Erros (MROB)"
603
+ ]]
604
+
605
+ # ======== KPIs Gerais ========
606
+ st.subheader("📈 KPIs (dados filtrados) — Geral (Todos)")
607
+ total_especialistas = grp_esp["Especialista"].nunique()
608
+ total_conferentes = grp_conf["Conferente"].nunique()
609
+ total_osm_geral = int(df_filt["Linhas OSM"].sum())
610
+ total_mrob_geral = int(df_filt["Linhas MROB"].sum())
611
+ total_erros_mrob_geral = int(df_filt["Linhas Erros MROB"].sum())
612
+ total_acertos_mrob_geral = (total_mrob_geral - total_erros_mrob_geral)
613
+ pct_acertos_geral = round((total_acertos_mrob_geral / total_mrob_geral * 100), 2) if total_mrob_geral > 0 else 0.0
614
+ pct_erros_geral = round((total_erros_mrob_geral / total_mrob_geral * 100), 2) if total_mrob_geral > 0 else 0.0
615
+
616
+ k1,k2,k3,k4,k5 = st.columns(5)
617
+ k1.metric("Especialistas", f"{total_especialistas}")
618
+ k2.metric("Conferentes", f"{total_conferentes}")
619
+ k3.metric("Linhas OSM (Σ)", f"{total_osm_geral:,}".replace(",", "."))
620
+ k4.metric("Linhas MROB (Σ)", f"{total_mrob_geral:,}".replace(",", "."))
621
+ color_geral = "#198754" if pct_acertos_geral >= meta_pct_especialistas else "#dc3545"
622
+ k5.metric("% Acertos (MROB/Geral)", f"{pct_acertos_geral}%")
623
+ # 🔧 HTML deve usar <span>...<span>, não entidades &lt;&gt;
624
+ st.markdown(
625
+ f"<span style='color:{color_geral}'>Meta (Especialistas): {meta_pct_especialistas}% • "
626
+ f"{'✅ Dentro da meta' if pct_acertos_geral >= meta_pct_especialistas else '⚠️ Abaixo da meta'}</span>",
627
+ unsafe_allow_html=True
628
+ )
629
+ st.markdown(f"<span style='color:#dc3545'>% Erros (MROB/Geral): {pct_erros_geral}%</span>", unsafe_allow_html=True)
630
+ st.divider()
631
+
632
+ # ======== KPIs por Especialista (foto + mini‑gráfico) ========
633
+ st.subheader("🎯 KPIs por Especialista")
634
+ especialistas_lista = ["(selecione)"] + list(grp_esp["Especialista"].astype(str).unique())
635
+ esp_sel = st.selectbox("Especialista:", especialistas_lista, index=0, key="prod_esp_kpi_esp")
636
+
637
+ if esp_sel and esp_sel != "(selecione)":
638
+ linha_esp = grp_esp[grp_esp["Especialista"] == esp_sel]
639
+ if not linha_esp.empty:
640
+ le_osm = int(linha_esp["Linhas OSM"].iloc[0])
641
+ le_mrob = int(linha_esp["Linhas MROB"].iloc[0])
642
+ le_err_mrob = int(linha_esp["Linhas Erros MROB"].iloc[0])
643
+ le_total_err = int(linha_esp["Total de Erros (MROB - Erros MROB)"].iloc[0])
644
+ le_pct_acertos = float(linha_esp["% Acertos (MROB)"].iloc[0]) if pd.notna(linha_esp["% Acertos (MROB)"].iloc[0]) else 0.0
645
+ le_pct_erros = float(linha_esp["% Erros (MROB)"].iloc[0]) if pd.notna(linha_esp["% Erros (MROB)"].iloc[0]) else 0.0
646
+
647
+ col_pic, col_chart, col_metrics = st.columns([1, 1.4, 3])
648
+ with col_pic:
649
+ dbp = SessionLocal()
650
+ try:
651
+ img_b, mt, updt = _get_foto(dbp, "especialista", esp_sel)
652
+ if img_b:
653
+ st.image(img_b, caption=f"{esp_sel}", use_container_width=False, width=220)
654
+ else:
655
+ st.caption("Sem foto cadastrada.")
656
+ finally:
657
+ dbp.close()
658
+ with col_chart:
659
+ serie = _serie_pct_mensal(df_filt, "Especialista", esp_sel, months=serie_meses)
660
+ _mini_grafico_pct_mensal(serie, meta=meta_pct_especialistas, chart_type=tipo_grafico, show_meta=linha_meta)
661
+ with st.expander("🔧 Diagnóstico da série (Especialista)", expanded=False):
662
+ st.dataframe(serie, use_container_width=True)
663
+ with col_metrics:
664
+ s1,s2,s3,s4,s5 = st.columns(5)
665
+ s1.metric("Linhas OSM", f"{le_osm:,}".replace(",", "."))
666
+ s2.metric("Linhas MROB", f"{le_mrob:,}".replace(",", "."))
667
+ s3.metric("Erros MROB", f"{le_err_mrob:,}".replace(",", "."))
668
+ s4.metric("Total Erros (MROB−Erros)", f"{le_total_err:,}".replace(",", "."))
669
+ s5.metric("% Acertos (MROB)", f"{le_pct_acertos}%")
670
+ # 🔧 HTML deve usar <span>...<span>, não entidades &lt;&gt;
671
+ st.markdown(f"<span style='color:#dc3545'>% Erros (MROB): {le_pct_erros}%</span>", unsafe_allow_html=True)
672
+ st.divider()
673
+
674
+ # ======== KPIs por Conferente (foto + mini‑gráfico) ========
675
+ st.subheader("🎯 KPIs por Conferente")
676
+ conferentes_lista = ["(selecione)"] + list(grp_conf["Conferente"].astype(str).unique())
677
+ conf_sel = st.selectbox("Conferente:", conferentes_lista, index=0, key="prod_esp_kpi_conf")
678
+
679
+ if conf_sel and conf_sel != "(selecione)":
680
+ linha_conf = grp_conf[grp_conf["Conferente"] == conf_sel]
681
+ if not linha_conf.empty:
682
+ lc_osm = int(linha_conf["Linhas OSM"].iloc[0])
683
+ lc_mrob = int(linha_conf["Linhas MROB"].iloc[0])
684
+ lc_err_mrob = int(linha_conf["Linhas Erros MROB"].iloc[0])
685
+ lc_total_err = int(linha_conf["Total de Erros (MROB - Erros MROB)"].iloc[0])
686
+ lc_pct_acertos = float(linha_conf["% Acertos (MROB)"].iloc[0]) if pd.notna(linha_conf["% Acertos (MROB)"].iloc[0]) else 0.0
687
+ lc_pct_erros = float(linha_conf["% Erros (MROB)"].iloc[0]) if pd.notna(linha_conf["% Erros (MROB)"].iloc[0]) else 0.0
688
+
689
+ col_pic2, col_chart2, col_metrics2 = st.columns([1, 1.4, 3])
690
+ with col_pic2:
691
+ dbp = SessionLocal()
692
+ try:
693
+ img_b, mt, updt = _get_foto(dbp, "conferente", conf_sel)
694
+ if img_b:
695
+ st.image(img_b, caption=f"{conf_sel}", use_container_width=False, width=220)
696
+ else:
697
+ st.caption("Sem foto cadastrada.")
698
+ finally:
699
+ dbp.close()
700
+ with col_chart2:
701
+ serie2 = _serie_pct_mensal(df_filt, "Conferente", conf_sel, months=serie_meses)
702
+ _mini_grafico_pct_mensal(serie2, meta=meta_pct_conferentes, chart_type=tipo_grafico, show_meta=linha_meta)
703
+ with st.expander("🔧 Diagnóstico da série (Conferente)", expanded=False):
704
+ st.dataframe(serie2, use_container_width=True)
705
+ with col_metrics2:
706
+ d1,d2,d3,d4,d5 = st.columns(5)
707
+ d1.metric("Linhas OSM", f"{lc_osm:,}".replace(",", "."))
708
+ d2.metric("Linhas MROB", f"{lc_mrob:,}".replace(",", "."))
709
+ d3.metric("Erros MROB", f"{lc_err_mrob:,}".replace(",", "."))
710
+ d4.metric("Total Erros (MROB−Erros)", f"{lc_total_err:,}".replace(",", "."))
711
+ d5.metric("% Acertos (MROB)", f"{lc_pct_acertos}%")
712
+ # 🔧 HTML deve usar <span>...<span>, não entidades &lt;&gt;
713
+ st.markdown(f"<span style='color:#dc3545'>% Erros (MROB): {lc_pct_erros}%</span>", unsafe_allow_html=True)
714
+
715
+ st.divider()
716
+
717
+ # ======== Listas e Gráficos maiores ========
718
+ st.subheader("🧾 Lista por Especialista (com métricas)")
719
+ st.dataframe(grp_esp, use_container_width=True)
720
+
721
+ st.subheader("🧾 Lista por Conferente (com métricas)")
722
+ st.dataframe(grp_conf, use_container_width=True)
723
+
724
+ st.subheader("📊 Gráficos")
725
+ try:
726
+ st.caption("Linhas OSM por Especialista (FPSO)")
727
+ st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas OSM"])
728
+ st.caption("Linhas MROB por Especialista (FPSO)")
729
+ st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas MROB"])
730
+ st.caption("Linhas de Erros MROB por Especialista (FPSO)")
731
+ st.bar_chart(data=grp_esp.set_index("Especialista (FPSO)")["Linhas Erros MROB"])
732
+ st.caption("Linhas OSM por Conferente (FPSO)")
733
+ st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas OSM"])
734
+ st.caption("Linhas MROB por Conferente (FPSO)")
735
+ st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas MROB"])
736
+ st.caption("Linhas de Erros MROB por Conferente (FPSO)")
737
+ st.bar_chart(data=grp_conf.set_index("Conferente (FPSO)")["Linhas Erros MROB"])
738
+ except Exception as e:
739
+ st.warning(f"Não foi possível renderizar alguns gráficos: {e}")
740
+
741
+ st.divider()
742
+
743
+ # ======== Exportação ========
744
+ st.subheader("⬇️ Exportar")
745
+ buffer_esp = BytesIO()
746
+ with pd.ExcelWriter(buffer_esp, engine="openpyxl") as writer:
747
+ grp_esp.to_excel(writer, index=False, sheet_name="Prod_Especialista")
748
+ buffer_esp.seek(0)
749
+ st.download_button(
750
+ label="⬇️ Exportar produtividade por Especialista (Excel)",
751
+ data=buffer_esp,
752
+ file_name="produtividade_especialista.xlsx",
753
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
754
+ key="prod_esp_export"
755
+ )
756
+
757
+ buffer_conf = BytesIO()
758
+ with pd.ExcelWriter(buffer_conf, engine="openpyxl") as writer:
759
+ grp_conf.to_excel(writer, index=False, sheet_name="Prod_Conferente")
760
+ buffer_conf.seek(0)
761
+ st.download_button(
762
+ label="⬇️ Exportar produtividade por Conferente (Excel)",
763
+ data=buffer_conf,
764
+ file_name="produtividade_conferente.xlsx",
765
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
766
+ key="prod_conf_export"
767
+ )
768
+
769
+ finally:
770
+ db.close()
771
+
772
+
773
+
774
+
775
+
776
+
777
+
778
+
add_pergunta.py ADDED
@@ -0,0 +1,129 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from banco import SessionLocal
2
+ from models import QuizPergunta, QuizResposta
3
+
4
+ def adicionar_pergunta(pergunta_texto, respostas, correta_index):
5
+ db = SessionLocal()
6
+ try:
7
+ # Cria a pergunta
8
+ pergunta = QuizPergunta(pergunta=pergunta_texto)
9
+ db.add(pergunta)
10
+ db.commit() # Gera o ID da pergunta para usar nas respostas
11
+ db.refresh(pergunta) # Atualiza o objeto com o ID do banco
12
+
13
+ # Cria as respostas vinculadas à pergunta
14
+ for i, texto in enumerate(respostas):
15
+ resposta = QuizResposta(
16
+ pergunta_id=pergunta.id,
17
+ texto=texto,
18
+ correta=(i == correta_index)
19
+ )
20
+ db.add(resposta)
21
+
22
+ db.commit()
23
+ print(f"Pergunta '{pergunta_texto}' adicionada com sucesso.")
24
+ except Exception as e:
25
+ db.rollback()
26
+ print(f"Erro ao adicionar pergunta: {e}")
27
+ finally:
28
+ db.close()
29
+
30
+ if __name__ == "__main__":
31
+ adicionar_pergunta(
32
+ "O que significa FPSO?",
33
+ [
34
+ "Floating Production Storage and Offloading",
35
+ "Fixed Production Storage Offshore",
36
+ "Floating Processing Supply Operation"
37
+ ],
38
+ 0
39
+ )
40
+
41
+ adicionar_pergunta(
42
+ "Qual é a principal função de um FPSO?",
43
+ [
44
+ "Armazenar contêineres",
45
+ "Produzir, armazenar e transferir petróleo",
46
+ "Transporte de passageiros"
47
+ ],
48
+ 1
49
+ )
50
+
51
+ adicionar_pergunta(
52
+ "Onde normalmente um FPSO opera?",
53
+ [
54
+ "Em portos comerciais",
55
+ "Em rios navegáveis",
56
+ "Em águas profundas e ultraprofundas"
57
+ ],
58
+ 2
59
+ )
60
+
61
+ adicionar_pergunta(
62
+ "Qual produto NÃO é normalmente processado em um FPSO?",
63
+ [
64
+ "Petróleo",
65
+ "Gás natural",
66
+ "Carvão mineral"
67
+ ],
68
+ 2
69
+ )
70
+
71
+ adicionar_pergunta(
72
+ "Qual etapa vem após a produção do petróleo em um FPSO?",
73
+ [
74
+ "Refino completo",
75
+ "Armazenamento e offloading",
76
+ "Transporte rodoviário"
77
+ ],
78
+ 1
79
+ )
80
+
81
+ adicionar_pergunta(
82
+ "O que significa o termo 'offloading'?",
83
+ [
84
+ "Processo de perfuração",
85
+ "Transferência de petróleo para navios aliviadores",
86
+ "Separação de óleo e gás"
87
+ ],
88
+ 1
89
+ )
90
+
91
+ adicionar_pergunta(
92
+ "Qual profissional é mais associado à operação diária de um FPSO?",
93
+ [
94
+ "Piloto de avião",
95
+ "Operador de produção offshore",
96
+ "Motorista de caminhão"
97
+ ],
98
+ 1
99
+ )
100
+
101
+ adicionar_pergunta(
102
+ "Qual risco é mais comum em operações offshore?",
103
+ [
104
+ "Congestionamento urbano",
105
+ "Derramamento de óleo",
106
+ "Falta de energia elétrica urbana"
107
+ ],
108
+ 1
109
+ )
110
+
111
+ adicionar_pergunta(
112
+ "Por que FPSOs são preferidos em campos distantes da costa?",
113
+ [
114
+ "Menor custo de construção",
115
+ "Dispensam oleodutos longos",
116
+ "Exigem menos tripulação"
117
+ ],
118
+ 1
119
+ )
120
+
121
+ adicionar_pergunta(
122
+ "Qual é um requisito essencial de segurança em FPSOs?",
123
+ [
124
+ "Plano de evacuação e emergência",
125
+ "Seguro veicular",
126
+ "Licença rodoviária"
127
+ ],
128
+ 0
129
+ )
administracao.py ADDED
@@ -0,0 +1,883 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ from datetime import datetime, date
5
+ from banco import SessionLocal
6
+ from models import Equipamento
7
+ from log import registrar_log
8
+ from utils_fpso import campo_fpso
9
+ from utils_permissoes import verificar_permissao
10
+
11
+ # 🔎 Utilitários SQLAlchemy para diagnóstico e migração simples
12
+ from sqlalchemy import inspect, text
13
+
14
+ # ⬇️ Import seguro do modelo AvisoGlobal (não quebra se ainda não existir)
15
+ try:
16
+ from models import AvisoGlobal
17
+ _HAS_AVISO_GLOBAL = True
18
+ except Exception:
19
+ _HAS_AVISO_GLOBAL = False
20
+
21
+ # =====================================================
22
+ # LISTAS FIXAS
23
+ # =====================================================
24
+ MODAL_LISTA = ["", "AÉREO", "MARÍTIMO", "EXPRESSO"]
25
+
26
+
27
+ # =====================================================
28
+ # MENU INFO (DOCUMENTAÇÃO INTERNA DO SISTEMA)
29
+ # =====================================================
30
+ def menu_info():
31
+
32
+ # ✅ Apêndice de documentação: novas funcionalidades e módulos (adicional)
33
+ doc_appendix()
34
+
35
+ st.info("📌 Documentação interna do sistema. Acesso restrito a administradores.")
36
+
37
+
38
+
39
+ # =====================================================
40
+ # APÊNDICE DE DOCUMENTAÇÃO (NOVAS FUNCIONALIDADES)
41
+ # =====================================================
42
+ def doc_appendix():
43
+ """
44
+ Adendo de documentação profissional que descreve
45
+ as novas funcionalidades, módulos e diretrizes sem
46
+ alterar o comportamento existente.
47
+ """
48
+ st.divider()
49
+ st.subheader("📘 Atualizações e Diretrizes Profissionais")
50
+
51
+ # ✅ NOVO: documentação padronizada do Módulo Formulário dentro do apêndice
52
+ with st.expander("🧾 Módulo Formulário (padrão)", expanded=False):
53
+ st.markdown("""
54
+ **Objetivo**
55
+ Registrar, de forma padronizada, os dados operacionais de equipamentos (FPSO, Modal, OSM, MROB, métricas e administrativos), garantindo rastreabilidade e qualidade das informações.
56
+
57
+ **Funcionalidades**
58
+ - Sugestões para **FPSO** e **FPSO1** via `campo_fpso`
59
+ - Campo controlado **“Outro”** quando aplicável
60
+ - Validação de **campos obrigatórios** (ex.: FPSO, Modal, OSM, MROB)
61
+ - Registro automático de **data/hora** (`data_hora_input`)
62
+ - Persistência completa em **banco de dados** (tabela `equipamentos`)
63
+ - **Auditoria**: ações de criação/edição/exclusão registradas
64
+
65
+ **Campos Principais (Operacionais)**
66
+ - **FPSO / FPSO1**: identificação
67
+ - **Data de Coleta**
68
+ - **Especialista / Conferente / OSM**
69
+ - **Modal / Quantidade de Equipamentos / MROB**
70
+ - **Métricas**: Linhas OSM, Linhas MROB, Linhas com Erro
71
+ - **Erros**: Storekeeper, Operação WH, Especialista WH, Outros
72
+ - **Inclusão / Exclusão** (D1, D2, D3)
73
+
74
+ **Dados Administrativos**
75
+ - **PO**, **Part Number**, **Material**, **Nota Fiscal**
76
+ - **Solicitante / Requisitante**
77
+ - **Impacto / Dimensão**
78
+ - **Motivo** (Inclusão/Exclusão)
79
+ - **Observações** (campo livre)
80
+
81
+ **Validações**
82
+ - Checagem de obrigatoriedade em campos críticos
83
+ - Tratamento de valores ausentes (fallback seguro)
84
+ - Índices/sugestões pré-carregados (FPSO/Modal/OSM)
85
+
86
+ **Fluxo de Dados**
87
+ 1. Usuário preenche o formulário com apoio de listas/sugestões
88
+ 2. Sistema valida campos e persiste em `equipamentos`
89
+ 3. Ação administrativa é registrada em **auditoria** (`log_acesso`)
90
+ 4. Registros editáveis posteriormente via **Administração de Registros**
91
+
92
+ **Perfis / Permissões**
93
+ - Acesso controlado por **perfil** (admin / usuario / consulta) via `verificar_permissao`
94
+
95
+ **Impacto**
96
+ - **Padronização** dos cadastros
97
+ - **Redução de erros** operacionais
98
+ - **Rastreabilidade** completa (auditoria + carimbo de data/hora)
99
+ """)
100
+
101
+ with st.expander("📚 Estrutura de Módulos e Grupos (modules_map.py)", expanded=False):
102
+ st.markdown("""
103
+ - **Grupos suportados**: Operação Load, Backload, Operação, Terceiros, BI.
104
+ - Cada módulo deve ter: `key`, `label`, `descricao`, `perfis`, `grupo`.
105
+ - O **menu lateral** exibe: `Pesquisar módulo` → `Selecione a operação (grupo)` → `Selecione o módulo`.
106
+ - Grupos **sem módulos** (ou sem permissão) exibem: _“Em desenvolvimento”_.
107
+ - **Boas práticas**: labels padronizados, `key` único (sem acentos e espaços), controle de acesso via `perfis`.
108
+ """)
109
+
110
+ with st.expander("🧭 Navegação e UI (menu lateral)", expanded=False):
111
+ st.markdown("""
112
+ - **Pesquisa**: filtra módulos pelo `label`.
113
+ - **Selectbox de Operação**: lista grupos disponíveis.
114
+ - **Selectbox de Módulo**: exibe módulos filtrados por grupo e permissões.
115
+ - **Rodapé da sidebar**: apresenta **e-mail do usuário logado** (badge alinhado) e bloco de **versão + desenvolvedor**.
116
+ - **Layout**: `st.set_page_config(layout="wide")` habilitado, área de conteúdo responsiva.
117
+ """)
118
+
119
+ with st.expander("📧 E-mail do Usuário Logado (login + sidebar)", expanded=False):
120
+ st.markdown("""
121
+ - `login.py` grava na sessão: `st.session_state.email` e `st.session_state.nome` (se disponíveis).
122
+ - Rodapé da sidebar exibe o e-mail em **formato badge** com ícone e alinhamento (`inline-flex`).
123
+ - Caso o e-mail não apareça: verifique se o usuário possui e-mail cadastrado e/ou revalide o login.
124
+ """)
125
+
126
+ with st.expander("🧾 Auditoria com E-mail", expanded=False):
127
+ st.markdown("""
128
+ - O módulo de auditoria realiza **JOIN** com `Usuario` e agora inclui **E-mail** na consulta.
129
+ - Exportação para Excel também leva a coluna **E-mail**.
130
+ - Observação: `JOIN` padrão é interno; para logs órfãos, use `outerjoin` (se necessário).
131
+ """)
132
+
133
+ with st.expander("🛠️ Banco de Dados e Ferramentas (db_tools)", expanded=False):
134
+ st.markdown("""
135
+ - 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`).
136
+ - **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).
137
+ - **Verificação de colunas**: `PRAGMA table_info(usuarios)` (SQLite) ou `information_schema.columns` (Postgres/MySQL).
138
+ """)
139
+
140
+ with st.expander("🎮 Jogos / Treinamento (módulo jogos)", expanded=False):
141
+ st.markdown("""
142
+ - **Jogo da Forca (Treinamento)**: perguntas por categoria, avanço de nível, contagem de tentativas.
143
+ - **Caça ao Tesouro (Níveis)**: pistas Sim/Não com feedback visual e avanço até o limite de perguntas.
144
+ - **Dado (Curiosidades)**: número de lados configurável, curiosidades de FPSO/Estoque/Óleo e Gás.
145
+ - **Pontuação e balões**: opção de efeitos visuais e pontuação acumulada.
146
+ """)
147
+
148
+ with st.expander("🧠 Quiz e Ranking", expanded=False):
149
+ st.markdown("""
150
+ - **Quiz**: perguntas dinâmicas via banco; fluxo ajustável (sem limitadores) e com opção de **Voltar ao sistema**.
151
+ - **Ranking**: consolida pontuação por rodada/período e oferece exportação.
152
+ """)
153
+
154
+ with st.expander("🎨 Diretrizes de Layout e Acessibilidade", expanded=False):
155
+ st.markdown("""
156
+ - **Responsividade**: usar `use_container_width=True` em tabelas/gráficos.
157
+ - **Colunas fluidas**: `st.columns()` para KPIs (ajuste automático em telas menores).
158
+ - **Expansores**: `st.expander()` para reduzir poluição visual.
159
+ - **Temas**: arquivo `.streamlit/config.toml` pode definir `primaryColor`, `secondaryBackgroundColor`, etc.
160
+ """)
161
+
162
+ with st.expander("🔐 Segurança e Boas Práticas", expanded=False):
163
+ st.markdown("""
164
+ - **Senhas**: sempre criptografadas (ex.: `utils_seguranca`), nunca armazenar em texto claro.
165
+ - **Perfis**: `verificar_permissao(mod_id)` controla acesso; mantenha perfis atualizados.
166
+ - **Auditoria**: registrar ações administrativas via `registrar_log(...)`.
167
+ """)
168
+
169
+ with st.expander("📦 Versionamento e Suporte", expanded=False):
170
+ st.markdown("""
171
+ - **Versão atual**: exibida no rodapé da sidebar.
172
+ - **Desenvolvedor**: contato visível na sidebar | Rodrigo Silva.
173
+ - **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).
174
+ """)
175
+
176
+
177
+ # =====================================================
178
+ # 🔔 Aviso Global — helpers
179
+ # =====================================================
180
+ def _get_db_session_admin():
181
+ """
182
+ Sessão ciente do ambiente atual (via db_router, quando disponível).
183
+ Fallback para SessionLocal().
184
+ """
185
+ try:
186
+ from db_router import get_session_for_current_db # ajuste o nome se necessário
187
+ return get_session_for_current_db()
188
+ except Exception:
189
+ return SessionLocal()
190
+
191
+ def _sanitize_largura(largura_raw: str) -> str:
192
+ val = (largura_raw or "").strip()
193
+ if not val:
194
+ return "100%"
195
+ if val.endswith("%") or val.endswith("px"):
196
+ return val
197
+ if val.isdigit():
198
+ return f"{val}px"
199
+ return "100%"
200
+
201
+ def _obter_aviso_ativo_admin():
202
+ if not _HAS_AVISO_GLOBAL:
203
+ return None
204
+ db = _get_db_session_admin()
205
+ try:
206
+ return (
207
+ db.query(AvisoGlobal)
208
+ .filter(AvisoGlobal.ativo == True)
209
+ .order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc())
210
+ .first()
211
+ )
212
+ except Exception:
213
+ return None
214
+ finally:
215
+ try:
216
+ db.close()
217
+ except Exception:
218
+ pass
219
+
220
+ # 🔧 Diagnóstico e correção de schema (colunas) da tabela aviso_global
221
+ def _verificar_schema_aviso_global(show_ui: bool = True) -> bool:
222
+ """
223
+ Retorna True se o schema está OK (inclui font_size).
224
+ Se show_ui=True, exibe UI com botão para criar coluna ausente.
225
+ """
226
+ if not _HAS_AVISO_GLOBAL:
227
+ if show_ui:
228
+ st.error("Modelo AvisoGlobal não encontrado.")
229
+ return False
230
+
231
+ db = _get_db_session_admin()
232
+ try:
233
+ insp = inspect(db.bind)
234
+ cols = [c["name"] for c in insp.get_columns("aviso_global")]
235
+ falta_font = "font_size" not in cols
236
+
237
+ if show_ui:
238
+ with st.expander("🧪 Diagnóstico do schema (aviso_global)", expanded=False):
239
+ st.caption("Colunas atuais: " + (", ".join(cols) if cols else "—"))
240
+ if falta_font:
241
+ st.warning("A coluna **font_size** não existe neste banco/ambiente.")
242
+ col_btn1, col_btn2 = st.columns([1, 3])
243
+ if col_btn1.button("⚙️ Criar coluna font_size (DEFAULT 14)"):
244
+ try:
245
+ dialect = db.bind.dialect.name
246
+ if dialect == "sqlite":
247
+ sql = "ALTER TABLE aviso_global ADD COLUMN font_size INTEGER DEFAULT 14"
248
+ elif dialect == "postgresql":
249
+ sql = "ALTER TABLE aviso_global ADD COLUMN font_size integer DEFAULT 14"
250
+ elif dialect in ("mysql", "mariadb"):
251
+ sql = "ALTER TABLE aviso_global ADD COLUMN font_size INT DEFAULT 14"
252
+ else:
253
+ st.error(f"Dialeto não suportado para criação automática: {dialect}")
254
+ return False
255
+ db.execute(text(sql))
256
+ db.commit()
257
+ st.success("Coluna 'font_size' criada com sucesso. Recarregando...")
258
+ st.rerun()
259
+ except Exception as e:
260
+ db.rollback()
261
+ st.error(f"Erro ao criar coluna: {e}")
262
+ else:
263
+ st.success("Schema OK ✔ (coluna 'font_size' presente).")
264
+ return not falta_font
265
+
266
+ except Exception as e:
267
+ if show_ui:
268
+ st.error(f"Falha ao inspecionar o schema: {e}")
269
+ return False
270
+ finally:
271
+ try:
272
+ db.close()
273
+ except Exception:
274
+ pass
275
+
276
+ def _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size) -> bool:
277
+ if not _HAS_AVISO_GLOBAL:
278
+ return False
279
+ db = _get_db_session_admin()
280
+ try:
281
+ # desativa os ativos
282
+ db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True).update({AvisoGlobal.ativo: False})
283
+ novo = AvisoGlobal(
284
+ mensagem=(mensagem or "").strip(),
285
+ bg_color=(bg_color or "#FFF3CD").strip(),
286
+ text_color=(text_color or "#664D03").strip(),
287
+ largura=_sanitize_largura(largura),
288
+ efeito=efeito if efeito in ("marquee", "fixo") else "marquee",
289
+ velocidade=max(5, min(int(velocidade or 20), 120)),
290
+ ativo=True,
291
+ updated_at=datetime.now(),
292
+ )
293
+ # salva font_size quando o atributo/coluna existir (fallback seguro)
294
+ try:
295
+ setattr(novo, "font_size", max(10, min(int(font_size or 14), 48)))
296
+ except Exception:
297
+ pass
298
+
299
+ db.add(novo)
300
+ db.commit()
301
+ db.expire_all()
302
+ return True
303
+ except Exception as e:
304
+ db.rollback()
305
+ # Diagnóstico visível para o admin
306
+ st.error(f"Falha ao publicar o aviso: {e}")
307
+ try:
308
+ insp = inspect(db.bind)
309
+ cols = [c["name"] for c in insp.get_columns("aviso_global")]
310
+ st.caption("Colunas em aviso_global: " + ", ".join(cols))
311
+ except Exception:
312
+ pass
313
+ return False
314
+ finally:
315
+ try:
316
+ db.close()
317
+ except Exception:
318
+ pass
319
+
320
+ def _desativar_aviso_admin() -> bool:
321
+ if not _HAS_AVISO_GLOBAL:
322
+ return False
323
+ db = _get_db_session_admin()
324
+ try:
325
+ db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True)\
326
+ .update({AvisoGlobal.ativo: False, AvisoGlobal.updated_at: datetime.now()})
327
+ db.commit()
328
+ db.expire_all()
329
+ return True
330
+ except Exception:
331
+ db.rollback()
332
+ return False
333
+ finally:
334
+ try:
335
+ db.close()
336
+ except Exception:
337
+ pass
338
+
339
+
340
+ # ===============================
341
+ # 🔎 Pré-visualização do Aviso Global (somente render local)
342
+ # ===============================
343
+ def _render_preview_aviso_topbar(mensagem: str, bg_color: str, text_color: str, largura: str, efeito: str, velocidade: int, font_size: int):
344
+ largura = _sanitize_largura(largura)
345
+ bg = (bg_color or "#FFF3CD").strip()
346
+ fg = (text_color or "#664D03").strip()
347
+ efeito = (efeito or "marquee").lower()
348
+ try:
349
+ velocidade = int(velocidade or 20)
350
+ except Exception:
351
+ velocidade = 20
352
+ try:
353
+ font_size = max(10, min(int(font_size or 14), 48))
354
+ except Exception:
355
+ font_size = 14
356
+
357
+ st.markdown(
358
+ f"""
359
+ <style>
360
+ .ag-topbar-wrap-preview {{
361
+ position: relative; /* preview não fixa no topo global */
362
+ width: {largura};
363
+ margin: 8px auto 10px auto;
364
+ z-index: 10;
365
+ background: {bg}; color: {fg};
366
+ border: 1px solid rgba(0,0,0,.08);
367
+ box-shadow: 0 2px 6px rgba(0,0,0,.06);
368
+ border-radius: 10px;
369
+ }}
370
+ .ag-topbar-inner-preview {{
371
+ display: flex; align-items: center;
372
+ min-height: 44px; padding: 8px 14px; overflow: hidden;
373
+ font-weight: 700; font-size: {font_size}px; letter-spacing: .2px;
374
+ white-space: nowrap;
375
+ }}
376
+ .ag-topbar-marquee-preview > span {{
377
+ display: inline-block; padding-left: 100%;
378
+ animation: ag-marquee-preview {velocidade}s linear infinite;
379
+ }}
380
+ @keyframes ag-marquee-preview {{
381
+ 0% {{ transform: translateX(0); }}
382
+ 100% {{ transform: translateX(-100%); }}
383
+ }}
384
+ </style>
385
+ <div class="ag-topbar-wrap-preview">
386
+ <div class="ag-topbar-inner-preview {'ag-topbar-marquee-preview' if efeito=='marquee' else ''}">
387
+ <span>{mensagem}</span>
388
+ </div>
389
+ </div>
390
+ """,
391
+ unsafe_allow_html=True
392
+ )
393
+
394
+
395
+ # =====================================================
396
+ # 🔔 Menu: Aviso Global (Topo)
397
+ # =====================================================
398
+ def menu_aviso_global():
399
+ st.subheader("📣 Aviso Global (Topo)")
400
+ st.caption("Envie um aviso global exibido no topo para todos os usuários.")
401
+
402
+ perfil = (st.session_state.get("perfil") or "usuario").strip().lower()
403
+ if perfil != "admin":
404
+ st.warning("Apenas administradores podem publicar avisos globais.")
405
+ return
406
+
407
+ if not _HAS_AVISO_GLOBAL:
408
+ st.error(
409
+ "O modelo `AvisoGlobal` não foi encontrado em `models.py`."
410
+ )
411
+ with st.expander("📄 Modelo necessário (copie para models.py)"):
412
+ st.code(
413
+ """from banco import Base
414
+ from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
415
+ from sqlalchemy.sql import func
416
+
417
+ class AvisoGlobal(Base):
418
+ __tablename__ = "aviso_global"
419
+ id = Column(Integer, primary_key=True, index=True)
420
+ mensagem = Column(Text, nullable=False)
421
+ bg_color = Column(String(32), default="#FFF3CD")
422
+ text_color = Column(String(32), default="#664D03")
423
+ largura = Column(String(16), default="100%")
424
+ efeito = Column(String(16), default="marquee")
425
+ velocidade = Column(Integer, default=20)
426
+ font_size = Column(Integer, default=14) # tamanho da fonte (px)
427
+ ativo = Column(Boolean, default=True, index=True)
428
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
429
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())""",
430
+ language="python",
431
+ )
432
+ return
433
+
434
+ # 🔎 Diagnóstico/migração simples do schema (font_size)
435
+ _verificar_schema_aviso_global(show_ui=True)
436
+
437
+ aviso_atual = _obter_aviso_ativo_admin()
438
+
439
+ msg_default = aviso_atual.mensagem if aviso_atual else ""
440
+ bg_default = aviso_atual.bg_color if aviso_atual else "#FFF3CD"
441
+ fg_default = aviso_atual.text_color if aviso_atual else "#664D03"
442
+ w_default = aviso_atual.largura if aviso_atual else "100%"
443
+ ef_default = (aviso_atual.efeito if aviso_atual else "marquee")
444
+ vel_default = int(aviso_atual.velocidade if aviso_atual else 20)
445
+ fs_default = int(getattr(aviso_atual, "font_size", 14)) if aviso_atual else 14 # ⬅️ NOVO
446
+
447
+ mensagem = st.text_input("Mensagem do aviso:", value=msg_default, placeholder="Ex.: Manutenção hoje às 18h...")
448
+ colc1, colc2 = st.columns(2)
449
+ bg_color = colc1.color_picker("Cor de fundo", value=bg_default)
450
+ text_color = colc2.color_picker("Cor do texto", value=fg_default)
451
+
452
+ colw1, colw2 = st.columns([2,1])
453
+ largura = colw1.text_input("Largura (ex.: 100% ou 1200px)", value=w_default)
454
+ efeito = colw2.selectbox("Efeito", ["marquee", "fixo"], index=(0 if ef_default=="marquee" else 1))
455
+
456
+ colv1, colv2 = st.columns(2)
457
+ velocidade = colv1.slider("Velocidade (segundos por ciclo)", min_value=5, max_value=120, value=vel_default, step=1, help="Usado apenas no modo 'marquee'.")
458
+ font_size = colv2.slider("Tamanho da fonte (px)", min_value=10, max_value=48, value=fs_default, step=1) # ⬅️ NOVO
459
+
460
+ # --- Pré-visualização ao vivo (sem salvar) ---
461
+ st.markdown("**Pré-visualização:**")
462
+ if (mensagem or "").strip():
463
+ _render_preview_aviso_topbar(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size)
464
+ else:
465
+ st.info("Digite a mensagem para ver a pré-visualização aqui.")
466
+
467
+ colb1, colb2, colb3 = st.columns(3)
468
+ publicar = colb1.button("📢 Publicar/Atualizar aviso")
469
+ desativar = colb2.button("🛑 Desativar aviso atual")
470
+ atualizar_preview = colb3.button("🔄 Atualizar prévia")
471
+
472
+ # Botão opcional de refresh da prévia (não salva nada; rerenderiza a página).
473
+ if atualizar_preview:
474
+ st.rerun()
475
+
476
+ if publicar:
477
+ if not (mensagem or "").strip():
478
+ st.warning("Digite a mensagem do aviso.")
479
+ else:
480
+ ok = _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size)
481
+ if ok:
482
+ try:
483
+ registrar_log(
484
+ usuario=st.session_state.get("usuario"),
485
+ acao="PUBLICAR_AVISO_GLOBAL",
486
+ tabela="aviso_global",
487
+ registro_id=None
488
+ )
489
+ except Exception:
490
+ pass
491
+ st.success("Aviso publicado/atualizado!")
492
+ st.rerun()
493
+ else:
494
+ st.error("Não foi possível publicar o aviso. Verifique o banco/logs.")
495
+
496
+ if desativar:
497
+ ok = _desativar_aviso_admin()
498
+ if ok:
499
+ try:
500
+ registrar_log(
501
+ usuario=st.session_state.get("usuario"),
502
+ acao="DESATIVAR_AVISO_GLOBAL",
503
+ tabela="aviso_global",
504
+ registro_id=None
505
+ )
506
+ except Exception:
507
+ pass
508
+ st.info("Aviso desativado.")
509
+ st.rerun()
510
+ else:
511
+ st.error("Não foi possível desativar o aviso.")
512
+
513
+
514
+ # =====================================================
515
+ # ADMINISTRAÇÃO (variação com abas/tabs)
516
+ # =====================================================
517
+ def main():
518
+
519
+ # ✅ Detecta se usuário é admin; abas administrativas aparecem apenas para admin.
520
+ is_admin = verificar_permissao("administracao")
521
+
522
+ # Título conforme perfil
523
+ if is_admin:
524
+ st.title("🔒 Administração")
525
+ # Admin vê todas as abas
526
+ tab_editar, tab_aviso, tab_info = st.tabs([
527
+ "✏️ Editar / Excluir Registros",
528
+ "📣 Aviso Global (Topo)",
529
+ "📘 Info do Sistema"
530
+ ])
531
+ else:
532
+ st.title("✏️ Edição de Registros")
533
+ # Não-admin vê apenas a aba de edição
534
+ (tab_editar,) = st.tabs(["✏️ Editar Registros"])
535
+
536
+ # =====================================================
537
+ # BLOCO: INFO DO SISTEMA (apenas admin)
538
+ # =====================================================
539
+ if is_admin:
540
+ with tab_info:
541
+ menu_info()
542
+
543
+ # =====================================================
544
+ # BLOCO: AVISO GLOBAL (apenas admin)
545
+ # =====================================================
546
+ with tab_aviso:
547
+ menu_aviso_global()
548
+
549
+ # =====================================================
550
+ # BLOCO: EDIÇÃO / EXCLUSÃO (excluir só admin)
551
+ # =====================================================
552
+ with tab_editar:
553
+
554
+ # =====================================================
555
+ # FUNÇÃO UTILITÁRIA
556
+ # =====================================================
557
+ def safe_index(lista, valor):
558
+ """Evita erro quando o valor salvo no banco não existe na lista"""
559
+ try:
560
+ return lista.index(valor)
561
+ except ValueError:
562
+ return 0
563
+
564
+ db = SessionLocal()
565
+ try:
566
+ # =====================================================
567
+ # 🔎 FILTROS OPCIONAIS COM SUGESTÕES DO BANCO
568
+ # (disponível para todos os perfis)
569
+ # =====================================================
570
+ st.subheader("🔎 Filtro de Busca (opcional)")
571
+
572
+ # IMPORTANTE: usar .distinct() sobre a coluna, como já estava
573
+ fpsos = [""] + sorted({r.fpso for r in db.query(Equipamento.fpso).distinct() if r.fpso})
574
+ modais = [""] + sorted({r.modal for r in db.query(Equipamento.modal).distinct() if r.modal})
575
+ osms = [""] + sorted({r.osm for r in db.query(Equipamento.osm).distinct() if r.osm})
576
+ # 🟩 NOVO: lista de Nota Fiscal para multiselect assistido
577
+ notas_dist = [""] + sorted({str(r.nota_fiscal) for r in db.query(Equipamento.nota_fiscal).distinct() if r.nota_fiscal})
578
+
579
+ col1, col2, col3, col4 = st.columns(4)
580
+
581
+ with col1:
582
+ filtro_fpso = st.selectbox("FPSO", fpsos)
583
+
584
+ with col2:
585
+ filtro_modal = st.selectbox("Modal", modais)
586
+
587
+ with col3:
588
+ filtro_osm = st.selectbox("OSM", osms)
589
+
590
+ with col4:
591
+ filtro_data = st.date_input("Data Coleta", value=None)
592
+
593
+ # 🟩 NOVO: filtros de Nota Fiscal + opção de ver só duplicadas
594
+ st.markdown("**🧾 Filtro por Nota Fiscal**")
595
+ nf_col1, nf_col2, nf_col3 = st.columns([2, 2, 1.2])
596
+ with nf_col1:
597
+ filtro_nf_text = st.text_input(
598
+ "Digite uma ou mais NFs (separadas por vírgula)",
599
+ value=""
600
+ )
601
+ with nf_col2:
602
+ filtro_nf_multi = st.multiselect(
603
+ "Ou selecione",
604
+ options=[x for x in notas_dist if x != ""]
605
+ )
606
+ with nf_col3:
607
+ mostrar_apenas_nf_duplicadas = st.checkbox(
608
+ "Somente duplicadas",
609
+ value=False
610
+ )
611
+
612
+ # =====================================================
613
+ # QUERY BASE (COMPORTAMENTO ORIGINAL) + NOVO FILTRO NF
614
+ # =====================================================
615
+ query = db.query(Equipamento)
616
+
617
+ if filtro_fpso:
618
+ query = query.filter(Equipamento.fpso == filtro_fpso)
619
+
620
+ if filtro_modal:
621
+ query = query.filter(Equipamento.modal == filtro_modal)
622
+
623
+ if filtro_osm:
624
+ query = query.filter(Equipamento.osm == filtro_osm)
625
+
626
+ if filtro_data:
627
+ query = query.filter(Equipamento.data_coleta == filtro_data)
628
+
629
+ # 🟩 NOVO: aplica filtro de Nota Fiscal (tratando como string)
630
+ notas_escolhidas = set()
631
+ if filtro_nf_text.strip():
632
+ partes = [p.strip() for p in filtro_nf_text.split(",") if p.strip()]
633
+ notas_escolhidas.update(partes)
634
+ if filtro_nf_multi:
635
+ notas_escolhidas.update([str(x).strip() for x in filtro_nf_multi if str(x).strip()])
636
+
637
+ if notas_escolhidas:
638
+ # Como a coluna é do tipo texto no modelo, filtramos por igualdade textual.
639
+ # Para outros dialetos/formatos numéricos, garantir cast adequado.
640
+ query = query.filter(Equipamento.nota_fiscal.in_(list(notas_escolhidas)))
641
+
642
+ registros = query.order_by(Equipamento.id.desc()).all()
643
+
644
+ if not registros:
645
+ st.info("Nenhum registro encontrado.")
646
+ return
647
+
648
+ # =====================================================
649
+ # 🧭 SINALIZAÇÃO DE NF DUPLICADA (no conjunto filtrado)
650
+ # =====================================================
651
+ # Monta DF auxiliar só com campos relevantes para contagem de NF
652
+ import pandas as pd
653
+ df_aux = pd.DataFrame([{
654
+ "ID": r.id,
655
+ "Nota Fiscal": ("" if r.nota_fiscal is None else str(r.nota_fiscal).strip())
656
+ } for r in registros])
657
+
658
+ # Contagem de ocorrências por NF (string, ignorando vazias)
659
+ if not df_aux.empty:
660
+ contagem = df_aux.loc[df_aux["Nota Fiscal"] != "", "Nota Fiscal"].value_counts()
661
+ notas_duplicadas = contagem[contagem > 1]
662
+ else:
663
+ notas_duplicadas = pd.Series(dtype=int)
664
+
665
+ # Aviso e expander com a lista das duplicadas
666
+ if len(notas_duplicadas.index) > 0:
667
+ total_ocorrencias = int(notas_duplicadas.sum())
668
+ st.warning(
669
+ f"⚠️ Foram encontradas **{total_ocorrencias}** ocorrências em **{len(notas_duplicadas)}** "
670
+ f"números de Nota Fiscal duplicados no resultado filtrado."
671
+ )
672
+ with st.expander("Ver lista de notas duplicadas"):
673
+ st.dataframe(
674
+ notas_duplicadas.rename("Ocorrências").reset_index().rename(columns={"index": "Nota Fiscal"}),
675
+ use_container_width=True
676
+ )
677
+
678
+ # Se marcado: mantém na lista apenas as duplicadas
679
+ if mostrar_apenas_nf_duplicadas:
680
+ set_dup = set(notas_duplicadas.index.tolist())
681
+ registros = [r for r in registros if (r.nota_fiscal is not None and str(r.nota_fiscal).strip() in set_dup)]
682
+
683
+ if not registros:
684
+ st.info("Nenhum registro duplicado após aplicar o filtro de 'Somente duplicadas'.")
685
+ return
686
+
687
+ else:
688
+ if mostrar_apenas_nf_duplicadas:
689
+ st.info("Não há notas duplicadas no conjunto filtrado.")
690
+ return
691
+
692
+ # =====================================================
693
+ # SELECTBOX DE ESCOLHA E FORMULÁRIO
694
+ # =====================================================
695
+ mapa = {
696
+ f"ID {r.id} | FPSO {r.fpso} | {r.modal} | {r.osm} | {r.data_coleta} | NF: {r.nota_fiscal or '—'}": r.id
697
+ for r in registros
698
+ }
699
+
700
+ escolha = st.selectbox("Selecione o registro", list(mapa.keys()))
701
+ registro = db.get(Equipamento, mapa[escolha])
702
+
703
+ st.divider()
704
+ st.subheader("✏️ Editar Registro")
705
+
706
+ # =====================================================
707
+ # FORMULÁRIO COMPLETO (MESMO DO MÓDULO FORMULÁRIO)
708
+ # =====================================================
709
+ with st.form("form_edicao"):
710
+
711
+ # ================== DADOS OPERACIONAIS ==================
712
+ st.subheader("📦 Dados Operacionais")
713
+
714
+ col1, col2, col3 = st.columns(3)
715
+
716
+ with col1:
717
+ fpso1 = campo_fpso("FPSO1", registro.fpso1)
718
+ fpso = campo_fpso("FPSO", registro.fpso)
719
+ data_coleta = st.date_input("Data de Coleta", registro.data_coleta)
720
+ especialista = st.text_input("Especialista", registro.especialista or "")
721
+ conferente = st.text_input("Conferente", registro.conferente or "")
722
+ osm = st.text_input("OSM", registro.osm or "")
723
+
724
+ with col2:
725
+ modal = st.selectbox(
726
+ "Modal",
727
+ MODAL_LISTA,
728
+ index=safe_index(MODAL_LISTA, registro.modal)
729
+ )
730
+ quant_equip = st.number_input(
731
+ "Quantidade de Equipamentos",
732
+ min_value=0,
733
+ value=registro.quant_equip or 0
734
+ )
735
+ mrob = st.text_input("MROB", registro.mrob or "")
736
+
737
+ with col3:
738
+ linhas_osm = st.number_input("Total de Linhas OSM", value=registro.linhas_osm or 0)
739
+ linhas_mrob = st.number_input("Total de Linhas MROB", value=registro.linhas_mrob or 0)
740
+ linhas_erros = st.number_input("Total de Linhas com Erro", value=registro.linhas_erros or 0)
741
+
742
+ st.divider()
743
+
744
+ # ================== ANÁLISE DE ERROS ==================
745
+ st.subheader("⚠️ Análise de Erros")
746
+
747
+ op_sim_nao = ["", "Sim", "Não"]
748
+
749
+ col_e1, col_e2, col_e3, col_e4 = st.columns(4)
750
+
751
+ with col_e1:
752
+ erro_storekeeper = st.selectbox(
753
+ "Storekeeper", op_sim_nao,
754
+ index=safe_index(op_sim_nao, registro.erro_storekeeper)
755
+ )
756
+
757
+ with col_e2:
758
+ erro_operacao = st.selectbox(
759
+ "Operação WH", op_sim_nao,
760
+ index=safe_index(op_sim_nao, registro.erro_operacao)
761
+ )
762
+
763
+ with col_e3:
764
+ erro_especialista = st.selectbox(
765
+ "Especialista WH", op_sim_nao,
766
+ index=safe_index(op_sim_nao, registro.erro_especialista)
767
+ )
768
+
769
+ with col_e4:
770
+ erro_outros = st.selectbox(
771
+ "Outros", op_sim_nao,
772
+ index=safe_index(op_sim_nao, registro.erro_outros)
773
+ )
774
+
775
+ op_inc_exc = ["", "INCLUSÃO", "EXCLUSÃO"]
776
+
777
+ inclusao_exclusao = st.selectbox(
778
+ "Inclusão / Exclusão",
779
+ op_inc_exc,
780
+ index=safe_index(op_inc_exc, registro.inclusao_exclusao)
781
+ )
782
+
783
+ st.divider()
784
+
785
+ # ================== DADOS ADMINISTRATIVOS ==================
786
+ st.subheader("🧾 Dados Administrativos")
787
+
788
+ col_a1, col_a2, col_a3 = st.columns(3)
789
+
790
+ with col_a1:
791
+ po = st.text_input("PO", registro.po or "")
792
+ part_number = st.text_input("Part Number", registro.part_number or "")
793
+
794
+ with col_a2:
795
+ material = st.text_input("Material", registro.material or "")
796
+ nota_fiscal = st.text_input("Nota Fiscal", registro.nota_fiscal or "")
797
+
798
+ with col_a3:
799
+ solicitante = st.text_input("Solicitante", registro.solicitante or "")
800
+ requisitante = st.text_input("Requisitante", registro.requisitante or "")
801
+
802
+ impacto = st.text_input("Impacto", registro.impacto or "")
803
+ dimensao = st.text_input("Dimensão", registro.dimensao or "")
804
+
805
+ # ✅ AJUSTE: corrigido para 'motivo'
806
+ motivo = st.text_input("Motivo da Inclusão / Exclusão", registro.motivo or "")
807
+
808
+ observacoes = st.text_area(
809
+ "Observações",
810
+ registro.observacoes or "",
811
+ height=120
812
+ )
813
+
814
+ op_dia = ["", "D1", "D2", "D3"]
815
+
816
+ dia_inclusao = st.selectbox(
817
+ "Dia de Inclusão (D)",
818
+ op_dia,
819
+ index=safe_index(op_dia, registro.dia_inclusao)
820
+ )
821
+
822
+ # ================== AÇÃO ==================
823
+ # 🔐 Apenas admin pode excluir
824
+ opcoes_acao = ["Salvar Alterações"] + (["Excluir Registro"] if is_admin else [])
825
+ acao = st.radio(
826
+ "Ação",
827
+ opcoes_acao,
828
+ horizontal=True
829
+ )
830
+
831
+ submit = st.form_submit_button("Confirmar")
832
+
833
+ # =====================================================
834
+ # AÇÕES
835
+ # =====================================================
836
+ if submit:
837
+
838
+ if acao == "Salvar Alterações":
839
+ # Atualiza todos os campos dinamicamente (exceto id)
840
+ for campo in registro.__table__.columns.keys():
841
+ if campo != "id":
842
+ setattr(registro, campo, locals().get(campo, getattr(registro, campo)))
843
+
844
+ registro.data_hora_input = datetime.now()
845
+ db.commit()
846
+
847
+ try:
848
+ registrar_log(
849
+ usuario=st.session_state.get("usuario"),
850
+ acao="EDITAR",
851
+ tabela="equipamentos",
852
+ registro_id=registro.id
853
+ )
854
+ except Exception:
855
+ pass
856
+
857
+ st.success("✅ Registro atualizado com sucesso!")
858
+ st.rerun()
859
+
860
+ elif acao == "Excluir Registro" and is_admin:
861
+ db.delete(registro)
862
+ db.commit()
863
+
864
+ try:
865
+ registrar_log(
866
+ usuario=st.session_state.get("usuario"),
867
+ acao="EXCLUIR",
868
+ tabela="equipamentos",
869
+ registro_id=registro.id
870
+ )
871
+ except Exception:
872
+ pass
873
+
874
+ st.success("🗑️ Registro excluído com sucesso!")
875
+ st.rerun()
876
+
877
+ finally:
878
+ try:
879
+ db.close()
880
+ except Exception:
881
+ pass
882
+
883
+
app.py ADDED
@@ -0,0 +1,1015 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ import streamlit as st
3
+ from dotenv import load_dotenv
4
+ from datetime import date, datetime, time
5
+
6
+ # ⬇️ Import correto das utils de operação
7
+ from utils_operacao import obter_grupos_disponiveis, obter_modulos_para_grupo
8
+
9
+ # ✅ Usa toda a largura da página (chamar antes de qualquer outro st.*)
10
+ st.set_page_config(layout="wide")
11
+
12
+ # Carrega variáveis de ambiente
13
+ load_dotenv()
14
+
15
+ # ===============================
16
+ # IMPORTAÇÃO DOS MÓDULOS
17
+ # ===============================
18
+ import formulario
19
+ import consulta
20
+ import relatorio
21
+ import administracao
22
+ import quiz
23
+ import ranking
24
+ import quiz_admin
25
+ import usuarios_admin
26
+ import videos
27
+ import auditoria
28
+ import importar_excel
29
+ import calendario
30
+ import auditoria_cleanup
31
+ import jogos
32
+ import db_tools
33
+ import db_admin
34
+ import db_monitor
35
+ import operacao
36
+ import db_export_import
37
+ import resposta # 📬 Admin: Caixa de Entrada IOI‑RUN (módulo interno)
38
+ import outlook_relatorio
39
+ import repositorio_load
40
+ import Produtividade_Especialista as produtividade_especialista
41
+ import rnc
42
+ import rnc_listagem
43
+ import rnc_relatorio
44
+ import sugestoes_usuario # 💡 Usuário: Sugestões IOI‑RUN (módulo separado)
45
+ import repo_rnc
46
+ import recebimento
47
+
48
+ from utils_info import INFO_CONTEUDO, INFO_MODULOS, INFO_MAP_PAGINA_ID
49
+ from login import login
50
+ from utils_permissoes import verificar_permissao
51
+ from utils_layout import exibir_logo
52
+ from modules_map import MODULES
53
+ from banco import engine, Base, SessionLocal
54
+ from models import QuizPontuacao
55
+ from models import IOIRunSugestao
56
+ from models import AvisoGlobal
57
+
58
+ # Extras p/ sessões ativas
59
+ from uuid import uuid4
60
+ from sqlalchemy import text, func, or_
61
+
62
+ # 🗄️ Banco ativo (Produção/Teste/Treinamento)
63
+ try:
64
+ from db_router import current_db_choice, bank_label
65
+ _HAS_ROUTER = True
66
+ except Exception:
67
+ _HAS_ROUTER = False
68
+ def current_db_choice() -> str:
69
+ return "prod"
70
+ def bank_label(choice: str) -> str:
71
+ return "🟢 Produção" if choice == "prod" else "🔴 Teste"
72
+ # ❌ REMOVIDO: não chamar nenhuma página ao importar/rodar o app principal
73
+ # if __name__ == "__main__":
74
+ # rnc.pagina()
75
+
76
+ # ===============================
77
+ # RERUN por querystring (atalho ?rr=1)
78
+ # ===============================
79
+ def _get_query_params():
80
+ """Compat: retorna query params como dict (Streamlit novo/antigo)."""
81
+ try:
82
+ # Streamlit >= 1.32
83
+ return dict(st.query_params)
84
+ except Exception:
85
+ # Streamlit antigo (experimental)
86
+ try:
87
+ return dict(st.experimental_get_query_params())
88
+ except Exception:
89
+ return {}
90
+
91
+ def _set_query_params(new_params: dict):
92
+ """Compat: define query params (Streamlit novo/antigo)."""
93
+ try:
94
+ st.query_params = new_params # Streamlit >= 1.32
95
+ except Exception:
96
+ try:
97
+ st.experimental_set_query_params(**new_params)
98
+ except Exception:
99
+ pass
100
+
101
+ def _check_rerun_qs(pagina_atual: str = ""):
102
+ """
103
+ Se a URL contiver rr=1 (ou true), força um rerun e limpa o parâmetro para evitar loop.
104
+ ✅ Não dispara quando estiver na página 'resposta' (Inbox Admin).
105
+ ✅ Consome apenas uma vez por sessão.
106
+ ✅ (PATCH) Também não dispara quando estiver em 'outlook_relatorio' para não interromper leitura COM.
107
+ """
108
+ try:
109
+ if st.session_state.get("__qs_rr_consumed__", False):
110
+ return
111
+ # 🔒 Evita rr=1 em módulos sensíveis a rerun/refresh
112
+ # 🟩 AJUSTE: incluir 'formulario' para não aplicar rr=1 quando o formulário estiver ativo
113
+ if pagina_atual in ("resposta", "outlook_relatorio", "formulario"):
114
+ return # não aplicar rr=1 dentro destes módulos (evita 'piscar' e cancelamentos)
115
+
116
+ params = _get_query_params()
117
+ rr_raw = params.get("rr", ["0"])
118
+ rr = rr_raw[0] if isinstance(rr_raw, (list, tuple)) else str(rr_raw)
119
+ if str(rr).lower() in ("1", "true"):
120
+ new_params = {k: v for k, v in params.items() if k != "rr"}
121
+ _set_query_params(new_params)
122
+ st.session_state["__qs_rr_consumed__"] = True
123
+ st.rerun()
124
+ except Exception:
125
+ pass
126
+
127
+ # =========================================
128
+ # DB helper — sessão ciente do ambiente
129
+ # =========================================
130
+ def _get_db_session():
131
+ """Retorna uma sessão de banco consistente com o ambiente atual."""
132
+ try:
133
+ from db_router import get_session_for_current_db
134
+ return get_session_for_current_db()
135
+ except Exception:
136
+ pass
137
+
138
+ try:
139
+ from db_router import get_engine_for_current_db
140
+ from sqlalchemy.orm import sessionmaker
141
+ Eng = get_engine_for_current_db()
142
+ return sessionmaker(bind=Eng)()
143
+ except Exception:
144
+ pass
145
+
146
+ return SessionLocal()
147
+
148
+ # ===============================
149
+ # CONFIGURAÇÃO INICIAL
150
+ # ===============================
151
+ Base.metadata.create_all(bind=engine)
152
+
153
+ def quiz_respondido_hoje(usuario: str) -> bool:
154
+ # ✅ Usar sessão ciente do ambiente
155
+ db = _get_db_session()
156
+ try:
157
+ inicio_dia = datetime.combine(date.today(), time.min)
158
+ return (
159
+ db.query(QuizPontuacao)
160
+ .filter(
161
+ QuizPontuacao.usuario == usuario,
162
+ QuizPontuacao.data >= inicio_dia
163
+ )
164
+ .first()
165
+ is not None
166
+ )
167
+ finally:
168
+ try:
169
+ db.close()
170
+ except Exception:
171
+ pass
172
+
173
+ # ===============================
174
+ # Sessões ativas (usuários logados agora)
175
+ # ===============================
176
+ _SESS_TTL_MIN = 5 # janela para considerar "online"
177
+
178
+ def _get_session_id() -> str:
179
+ if "_sid" not in st.session_state:
180
+ st.session_state["_sid"] = f"{uuid4()}"
181
+ return st.session_state["_sid"]
182
+
183
+ def _ensure_sessao_table(db) -> None:
184
+ """Cria a tabela sessao_web caso não exista (SQLite/Postgres/MySQL)."""
185
+ dialect = db.bind.dialect.name
186
+ if dialect == "sqlite":
187
+ db.execute(text("""
188
+ CREATE TABLE IF NOT EXISTS sessao_web (
189
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
190
+ usuario TEXT NOT NULL,
191
+ session_id TEXT NOT NULL UNIQUE,
192
+ last_seen TIMESTAMP NOT NULL,
193
+ ativo INTEGER NOT NULL DEFAULT 1
194
+ )
195
+ """))
196
+ elif dialect in ("postgresql", "postgres"):
197
+ db.execute(text("""
198
+ CREATE TABLE IF NOT EXISTS sessao_web (
199
+ id SERIAL PRIMARY KEY,
200
+ usuario TEXT NOT NULL,
201
+ session_id TEXT NOT NULL UNIQUE,
202
+ last_seen TIMESTAMPTZ NOT NULL,
203
+ ativo BOOLEAN NOT NULL DEFAULT TRUE
204
+ )
205
+ """))
206
+ else: # mysql / mariadb
207
+ db.execute(text("""
208
+ CREATE TABLE IF NOT EXISTS sessao_web (
209
+ id INT AUTO_INCREMENT PRIMARY KEY,
210
+ usuario VARCHAR(255) NOT NULL,
211
+ session_id VARCHAR(255) NOT NULL UNIQUE,
212
+ last_seen TIMESTAMP NOT NULL,
213
+ ativo TINYINT(1) NOT NULL DEFAULT 1
214
+ )
215
+ """))
216
+ db.commit()
217
+
218
+ def _session_heartbeat(usuario: str) -> None:
219
+ """Atualiza/insere a sessão ativa do usuário com last_seen = now() e faz limpeza básica."""
220
+ if not usuario:
221
+ return
222
+ db = _get_db_session()
223
+ try:
224
+ _ensure_sessao_table(db)
225
+ sid = _get_session_id()
226
+ now_sql = "CURRENT_TIMESTAMP"
227
+
228
+ upd = db.execute(
229
+ text(f"UPDATE sessao_web SET last_seen = {now_sql}, ativo = 1 WHERE session_id = :sid"),
230
+ {"sid": sid}
231
+ )
232
+ if upd.rowcount == 0:
233
+ db.execute(
234
+ text(f"INSERT INTO sessao_web (usuario, session_id, last_seen, ativo) "
235
+ f"VALUES (:usuario, :sid, {now_sql}, 1)"),
236
+ {"usuario": usuario, "sid": sid}
237
+ )
238
+
239
+ dialect = db.bind.dialect.name
240
+ if dialect in ("postgresql", "postgres"):
241
+ cleanup_sql = f"UPDATE sessao_web SET ativo = FALSE WHERE last_seen < (NOW() - INTERVAL '{_SESS_TTL_MIN * 2} minutes')"
242
+ elif dialect == "sqlite":
243
+ cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN * 2} minutes')"
244
+ else:
245
+ cleanup_sql = f"UPDATE sessao_web SET ativo = 0 WHERE last_seen < DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN * 2} MINUTE)"
246
+ db.execute(text(cleanup_sql))
247
+ db.commit()
248
+ except Exception:
249
+ db.rollback()
250
+ finally:
251
+ try:
252
+ db.close()
253
+ except Exception:
254
+ pass
255
+
256
+ def _get_active_users_count() -> int:
257
+ """Conta usuários distintos com last_seen dentro da janela (_SESS_TTL_MIN) e ativo=1."""
258
+ db = _get_db_session()
259
+ try:
260
+ _ensure_sessao_table(db)
261
+ dialect = db.bind.dialect.name
262
+ if dialect in ("postgresql", "postgres"):
263
+ threshold = f"(NOW() - INTERVAL '{_SESS_TTL_MIN} minutes')"
264
+ elif dialect == "sqlite":
265
+ threshold = f"datetime(CURRENT_TIMESTAMP, '-{_SESS_TTL_MIN} minutes')"
266
+ else:
267
+ threshold = f"DATE_SUB(CURRENT_TIMESTAMP, INTERVAL {_SESS_TTL_MIN} MINUTE)"
268
+ res = db.execute(
269
+ text(f"SELECT COUNT(DISTINCT usuario) AS c FROM sessao_web WHERE ativo = 1 AND last_seen >= {threshold}")
270
+ ).fetchone()
271
+ return int(res[0] if res and res[0] is not None else 0)
272
+ except Exception:
273
+ return 0
274
+ finally:
275
+ try:
276
+ db.close()
277
+ except Exception:
278
+ pass
279
+
280
+ def _mark_session_inactive() -> None:
281
+ """Marca a sessão atual como inativa (chamar no logout)."""
282
+ sid = st.session_state.get("_sid")
283
+ if not sid:
284
+ return
285
+ db = _get_db_session()
286
+ try:
287
+ _ensure_sessao_table(db)
288
+ db.execute(text("UPDATE sessao_web SET ativo = 0 WHERE session_id = :sid"), {"sid": sid})
289
+ db.commit()
290
+ except Exception:
291
+ db.rollback()
292
+ finally:
293
+ try:
294
+ db.close()
295
+ except Exception:
296
+ pass
297
+
298
+ # ===============================
299
+ # Aviso Global — Util (leitura e sanitização)
300
+ # ===============================
301
+ def _sanitize_largura(largura_raw: str) -> str:
302
+ val = (largura_raw or "").strip()
303
+ if not val:
304
+ return "100%"
305
+ if val.endswith("%") or val.endswith("px"):
306
+ return val
307
+ if val.isdigit():
308
+ return f"{val}px"
309
+ return "100%"
310
+
311
+ def obter_aviso_ativo(db):
312
+ try:
313
+ aviso = (
314
+ db.query(AvisoGlobal)
315
+ .filter(AvisoGlobal.ativo == True)
316
+ .order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc())
317
+ .first()
318
+ )
319
+ return aviso
320
+ except Exception:
321
+ return None
322
+
323
+ # ===============================
324
+ # Aviso Global — Render do banner superior (robusto)
325
+ # ===============================
326
+ def _render_aviso_global_topbar():
327
+ try:
328
+ db = _get_db_session()
329
+ except Exception as e:
330
+ st.sidebar.warning(f"Aviso desativado: sessão indisponível ({e})")
331
+ return
332
+
333
+ aviso = None
334
+ try:
335
+ aviso = obter_aviso_ativo(db)
336
+ except Exception as e:
337
+ st.sidebar.warning(f"Aviso desativado: falha ao consultar ({e})")
338
+ aviso = None
339
+ finally:
340
+ try:
341
+ db.close()
342
+ except Exception:
343
+ pass
344
+
345
+ if not aviso:
346
+ return
347
+
348
+ try:
349
+ largura = _sanitize_largura(aviso.largura)
350
+ bg = aviso.bg_color or "#FFF3CD"
351
+ fg = aviso.text_color or "#664D03"
352
+ efeito = (aviso.efeito or "marquee").lower()
353
+ velocidade = int(aviso.velocidade or 20)
354
+ try:
355
+ font_size = max(10, min(int(getattr(aviso, "font_size", 14)), 48))
356
+ except Exception:
357
+ font_size = 14
358
+
359
+ altura = 52 # px
360
+
361
+ st.markdown(
362
+ f"""
363
+ <style>
364
+ /* Não derrube overlays do Streamlit */
365
+ .stApp::before,
366
+ header[data-testid="stHeader"],
367
+ [data-testid="stToolbar"],
368
+ [data-testid="stDecoration"],
369
+ [data-testid="collapsedControl"],
370
+ .stApp [class*="stDialog"] {{
371
+ z-index: 1 !important;
372
+ }}
373
+ /* Reserva espaço para a barra */
374
+ [data-testid="stAppViewContainer"] {{
375
+ padding-top: {altura + 8}px !important;
376
+ }}
377
+
378
+ .ag-topbar-wrap {{
379
+ position: fixed;
380
+ top: 0;
381
+ left: 0;
382
+ width: {largura};
383
+ z-index: 2147483647 !important;
384
+ background: {bg};
385
+ color: {fg};
386
+ border-bottom: 1px solid rgba(0,0,0,.12);
387
+ box-shadow: 0 2px 8px rgba(0,0,0,.15);
388
+ border-radius: 0 0 10px 10px;
389
+ pointer-events: none;
390
+ }}
391
+ .ag-topbar-inner {{
392
+ display: flex;
393
+ align-items: center;
394
+ height: {altura}px;
395
+ padding: 0 14px;
396
+ overflow: hidden;
397
+ font-weight: 700;
398
+ font-size: {font_size}px;
399
+ letter-spacing: .2px;
400
+ white-space: nowrap;
401
+ }}
402
+ .ag-topbar-marquee > span {{
403
+ display: inline-block;
404
+ padding-left: 100%;
405
+ animation: ag-marquee {velocidade}s linear infinite;
406
+ }}
407
+ @keyframes ag-marquee {{
408
+ 0% {{ transform: translateX(0); }}
409
+ 100% {{ transform: translateX(-100%); }}
410
+ }}
411
+ /* Acessibilidade: reduz movimento */
412
+ @media (prefers-reduced-motion: reduce) {{
413
+ .ag-topbar-marquee > span {{
414
+ animation: none !important;
415
+ padding-left: 0;
416
+ }}
417
+ }}
418
+ @media (max-width: 500px) {{
419
+ .ag-topbar-inner {{
420
+ font-size: {max(10, font_size-3)}px;
421
+ padding: 0 8px;
422
+ height: 44px;
423
+ }}
424
+ [data-testid="stAppViewContainer"] {{
425
+ padding-top: 52px !important;
426
+ }}
427
+ }}
428
+ </style>
429
+
430
+ <div class="ag-topbar-wrap">
431
+ <div class="ag-topbar-inner {'ag-topbar-marquee' if efeito=='marquee' else ''}">
432
+ <span>{aviso.mensagem}</span>
433
+ </div>
434
+ </div>
435
+ """,
436
+ unsafe_allow_html=True
437
+ )
438
+ except Exception as e:
439
+ st.sidebar.warning(f"Aviso desativado: erro de render ({e})")
440
+ return
441
+
442
+ # ===============================
443
+ # Logout (utilitário)
444
+ # ===============================
445
+ def logout():
446
+ """Finaliza a sessão do usuário, limpa estados e recarrega a aplicação."""
447
+ _mark_session_inactive() # marca esta sessão como inativa
448
+ st.session_state.logado = False
449
+ st.session_state.usuario = None
450
+ st.session_state.perfil = None
451
+ st.session_state.nome = None
452
+ st.session_state.email = None
453
+ st.session_state.quiz_verificado = False
454
+ st.rerun()
455
+
456
+ # ===============================
457
+ # 🎂 Banner/efeito de aniversário
458
+ # ===============================
459
+ def _show_birthday_banner_if_needed():
460
+ if st.session_state.get("__show_birthday__"):
461
+ st.session_state["__show_birthday__"] = False
462
+ st.markdown(
463
+ """
464
+ <style>
465
+ .confetti-wrapper { position: relative; width: 100%; height: 0; }
466
+ .confetti-area { position: fixed; top: 0; left: 0; width: 100%; height: 100%;
467
+ pointer-events: none; z-index: 9999; }
468
+ .confetti { position: absolute; top: -5%; font-size: 24px; animation-name: confetti-fall;
469
+ animation-timing-function: linear; animation-iteration-count: 1; }
470
+ @keyframes confetti-fall {
471
+ 0% { transform: translateY(-5vh) rotate(0deg); opacity: 1; }
472
+ 100% { transform: translateY(105vh) rotate(360deg); opacity: 0; }
473
+ }
474
+ .confetti:nth-child(1) { left: 5%; animation-duration: 3.5s; }
475
+ .confetti:nth-child(2) { left: 12%; animation-duration: 4.0s; }
476
+ .confetti:nth-child(3) { left: 20%; animation-duration: 3.2s; }
477
+ .confetti:nth-child(4) { left: 28%; animation-duration: 4.3s; }
478
+ .confetti:nth-child(5) { left: 36%; animation-duration: 3.8s; }
479
+ .confetti:nth-child(6) { left: 44%; animation-duration: 4.1s; }
480
+ .confetti:nth-child(7) { left: 52%; animation-duration: 3.4s; }
481
+ .confetti:nth-child(8) { left: 60%; animation-duration: 4.4s; }
482
+ .confetti:nth-child(9) { left: 68%; animation-duration: 3.9s; }
483
+ .confetti:nth-child(10) { left: 76%; animation-duration: 4.2s; }
484
+ .confetti:nth-child(11) { left: 84%; animation-duration: 3.6s; }
485
+ .confetti:nth-child(12) { left: 92%; animation-duration: 4.0s; }
486
+ </style>
487
+ <div class="confetti-wrapper">
488
+ <div class="confetti-area">
489
+ <div class="confetti">🎊</div><div class="confetti">🎉</div>
490
+ <div class="confetti">🎊</div><div class="confetti">🎉</div>
491
+ <div class="confetti">🎊</div><div class="confetti">🎉</div>
492
+ <div class="confetti">🎊</div><div class="confetti">🎉</div>
493
+ <div class="confetti">🎊</div><div class="confetti">🎉</div>
494
+ <div class="confetti">🎊</div><div class="confetti">🎉</div>
495
+ </div>
496
+ </div>
497
+ """,
498
+ unsafe_allow_html=True
499
+ )
500
+ st.balloons()
501
+ nome = st.session_state.get("nome") or st.session_state.get("usuario") or "Usuário"
502
+ st.markdown(
503
+ f"""
504
+ <div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin:40px 0;">
505
+ <div style="font-size: 36px; font-weight: 800; color:#A020F0;
506
+ background:linear-gradient(90deg,#FFF0F6,#F0E6FF);
507
+ padding:20px 30px; border-radius:16px; box-shadow:0 4px 10px rgba(0,0,0,.08);">
508
+ 🎉 Feliz Aniversário, {nome}! 🎉
509
+ </div>
510
+ </div>
511
+ """,
512
+ unsafe_allow_html=True
513
+ )
514
+ COR_FRASE = "#0d6efd"
515
+ st.markdown(
516
+ f"""
517
+ <div style="display:flex;justify-content:center;align-items:center;text-align:center;width:100%;margin-top:10px;">
518
+ <div style="font-size: 20px; font-weight: 500; color:{COR_FRASE}; padding:10px;">
519
+ Desejamos a você muitas conquistas e bons embarques ao longo do ano! 💜
520
+ </div>
521
+ </div>
522
+ """,
523
+ unsafe_allow_html=True
524
+ )
525
+
526
+ # ===============================
527
+ # MAIN
528
+ # ===============================
529
+ def main():
530
+ # Estados iniciais
531
+ if "logado" not in st.session_state:
532
+ st.session_state.logado = False
533
+ if "usuario" not in st.session_state:
534
+ st.session_state.usuario = None
535
+ if "quiz_verificado" not in st.session_state:
536
+ st.session_state.quiz_verificado = False
537
+ if "user_responses_viewed" not in st.session_state:
538
+ st.session_state.user_responses_viewed = False
539
+ if "nav_target" not in st.session_state:
540
+ st.session_state.nav_target = None
541
+
542
+ # ✅ Estado do intervalo de autoatualização (padrão aumentado p/ 60s; 0 = desligado)
543
+ st.session_state.setdefault("__auto_refresh_interval_sec__", 60)
544
+
545
+ # LOGIN
546
+ if not st.session_state.logado:
547
+ st.session_state.quiz_verificado = False
548
+ exibir_logo(top=True, sidebar=False)
549
+ login()
550
+ return
551
+
552
+ # 👥 Heartbeat + Badge de usuários logados (APENAS ADMIN)
553
+ _session_heartbeat(st.session_state.usuario)
554
+ if (st.session_state.get("perfil") or "").strip().lower() == "admin":
555
+ try:
556
+ online_now = _get_active_users_count()
557
+ except Exception:
558
+ online_now = 0
559
+ st.sidebar.markdown(
560
+ f"""
561
+ <div style="padding:8px 10px;margin-top:6px;margin-bottom:6px;border-radius:8px;
562
+ background:#1e293b; color:#e2e8f0; border:1px solid #334155;">
563
+ <span style="font-size:13px;">🟢 Online (últimos {_SESS_TTL_MIN} min)</span><br>
564
+ <span style="font-size:22px;font-weight:800;">{online_now}</span>
565
+ </div>
566
+ """,
567
+ unsafe_allow_html=True
568
+ )
569
+
570
+ # 🔄 Botão de Recarregar (mantém a sessão ativa) + ⏱️ Controle do intervalo
571
+ st.sidebar.markdown("---")
572
+ # Linha com botão de recarregar e popover para o intervalo
573
+ col_reload, col_interval = st.sidebar.columns([1, 1])
574
+ if col_reload.button("🔄 Recarregar (sem sair)", key="__btn_reload_now__"):
575
+ st.rerun()
576
+
577
+ # Popover (se disponível) para configurar intervalo; fallback para expander
578
+ if hasattr(st, "popover"):
579
+ with col_interval.popover("⏱️ Autoatualização"):
580
+ new_val = st.number_input(
581
+ "Intervalo (segundos) — 0 desativa",
582
+ min_value=0, max_value=3600,
583
+ value=int(st.session_state["__auto_refresh_interval_sec__"]),
584
+ step=5, key="__auto_refresh_input__"
585
+ )
586
+ if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
587
+ st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
588
+ try:
589
+ if int(new_val) > 0:
590
+ st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
591
+ else:
592
+ st.toast("Autoatualização desativada.", icon="⛔")
593
+ except Exception:
594
+ pass
595
+ st.rerun()
596
+ else:
597
+ with st.sidebar.expander("⏱️ Autoatualização", expanded=False):
598
+ new_val = st.number_input(
599
+ "Intervalo (segundos) — 0 desativa",
600
+ min_value=0, max_value=3600,
601
+ value=int(st.session_state["__auto_refresh_interval_sec__"]),
602
+ step=5, key="__auto_refresh_input__"
603
+ )
604
+ if st.button("Aplicar intervalo", key="__btn_apply_auto_refresh__"):
605
+ st.session_state["__auto_refresh_interval_sec__"] = int(new_val)
606
+ try:
607
+ if int(new_val) > 0:
608
+ st.toast(f"Autoatualização ajustada para {int(new_val)}s.", icon="⏱️")
609
+ else:
610
+ st.toast("Autoatualização desativada.", icon="⛔")
611
+ except Exception:
612
+ pass
613
+ st.rerun()
614
+
615
+ usuario = st.session_state.usuario
616
+ perfil = (st.session_state.get("perfil") or "usuario").strip().lower()
617
+
618
+ # QUIZ
619
+ if not st.session_state.quiz_verificado:
620
+ if not quiz_respondido_hoje(usuario):
621
+ exibir_logo(top=True, sidebar=False)
622
+ quiz.main()
623
+ return
624
+ else:
625
+ st.session_state.quiz_verificado = True
626
+ st.rerun()
627
+
628
+ # SISTEMA LIBERADO
629
+ exibir_logo(top=True, sidebar=True)
630
+ _render_aviso_global_topbar()
631
+ _show_birthday_banner_if_needed()
632
+
633
+ st.sidebar.markdown("### Menu | 🎉 Carnaval 🎭 2026!")
634
+
635
+ # Banco ativo na sidebar
636
+ try:
637
+ banco_label = bank_label(current_db_choice()) if _HAS_ROUTER else (
638
+ "🟢 Produção" if current_db_choice() == "prod" else "🔴 Teste"
639
+ )
640
+ st.sidebar.caption(f"🗄️ Banco ativo: {banco_label}")
641
+ except Exception:
642
+ pass
643
+
644
+ # =========================
645
+ # Notificações no sidebar
646
+ # =========================
647
+
648
+ # --- Admin: pendentes ---
649
+ if perfil == "admin":
650
+ try:
651
+ db = _get_db_session()
652
+ pendentes = db.query(IOIRunSugestao).filter(func.lower(IOIRunSugestao.status) == "pendente").count()
653
+ except Exception:
654
+ pendentes = 0
655
+ finally:
656
+ try: db.close()
657
+ except Exception: pass
658
+
659
+ if pendentes > 0:
660
+ st.sidebar.markdown(
661
+ """
662
+ <div style="padding:8px 10px;border-radius:8px;background:#FFF3CD;color:#664D03;
663
+ border:1px solid #FFECB5;margin-bottom:6px;">
664
+ <b>🔔 {pendentes} sugestão(ões) pendente(s)</b><br>
665
+ <span style="font-size:12px;">Acesse a caixa de entrada para responder.</span>
666
+ </div>
667
+ """.format(pendentes=pendentes),
668
+ unsafe_allow_html=True
669
+ )
670
+
671
+ # 👉 Direciona para o MESMO módulo do menu (resposta.main())
672
+ if st.sidebar.button("📬 Abrir Caixa de Entrada (Admin)"):
673
+ st.session_state.nav_target = "resposta"
674
+ st.rerun()
675
+
676
+ # --- Usuário: respostas novas (após último 'visto') ---
677
+ if perfil != "admin":
678
+ # Última vez que o usuário realmente abriu e visualizou as respostas
679
+ last_seen_dt = st.session_state.get("__user_last_answer_seen__")
680
+
681
+ try:
682
+ db = _get_db_session()
683
+
684
+ # Qual é a resposta mais recente existente
685
+ last_answer_dt_row = (
686
+ db.query(IOIRunSugestao.data_resposta)
687
+ .filter(
688
+ IOIRunSugestao.usuario == usuario,
689
+ func.lower(IOIRunSugestao.status) == "respondida",
690
+ IOIRunSugestao.data_resposta != None
691
+ )
692
+ .order_by(IOIRunSugestao.data_resposta.desc())
693
+ .first()
694
+ )
695
+ last_answer_dt = last_answer_dt_row[0] if last_answer_dt_row else None
696
+
697
+ # Se há algo mais novo do que o 'visto', marcamos como não visto
698
+ if last_answer_dt and (not last_seen_dt or last_answer_dt > last_seen_dt):
699
+ st.session_state.user_responses_viewed = False
700
+
701
+ # ✅ Conta SOMENTE respostas novas (depois do 'last_seen_dt')
702
+ novas_respostas = (
703
+ db.query(IOIRunSugestao)
704
+ .filter(
705
+ IOIRunSugestao.usuario == usuario,
706
+ func.lower(IOIRunSugestao.status) == "respondida",
707
+ (IOIRunSugestao.data_resposta > last_seen_dt) if last_seen_dt else (IOIRunSugestao.data_resposta != None)
708
+ )
709
+ .count()
710
+ )
711
+ except Exception:
712
+ novas_respostas = 0
713
+ finally:
714
+ try: db.close()
715
+ except Exception: pass
716
+
717
+ # ✅ Exibir card de nova mensagem até o usuário clicar em "Ver respostas"
718
+ if novas_respostas > 0 and not st.session_state.get("user_responses_viewed", False):
719
+ st.sidebar.markdown(
720
+ """
721
+ <div style="padding:8px 10px;border-radius:8px;background:#D1E7DD;color:#0F5132;
722
+ border:1px solid #BADBCC;margin-bottom:6px;">
723
+ <b>🔔 {resps} resposta(s) nova(s) para suas sugestões</b><br>
724
+ <span style="font-size:12px;">Clique para ver suas respostas.</span>
725
+ </div>
726
+ """.format(resps=novas_respostas),
727
+ unsafe_allow_html=True
728
+ )
729
+
730
+ # (Opcional) Toast discreto — aparece uma única vez por sessão enquanto houver novidade
731
+ if not st.session_state.get("__user_toast_shown__"):
732
+ try:
733
+ st.toast("Você tem novas respostas do IOI‑RUN. Clique em '📥 Ver respostas'.", icon="💬")
734
+ except Exception:
735
+ pass
736
+ st.session_state["__user_toast_shown__"] = True
737
+
738
+ if st.sidebar.button("📥 Ver respostas"):
739
+ # Não atualizamos last_seen aqui; isso é feito dentro do módulo do usuário
740
+ st.session_state.nav_target = "sugestoes_ioirun"
741
+ st.session_state.user_responses_viewed = True
742
+ st.rerun()
743
+ else:
744
+ # Se não há novidades, libera o toast para a próxima vez que houver
745
+ st.session_state["__user_toast_shown__"] = False
746
+
747
+ # ------------------------- Menu lateral -------------------------
748
+ termo_busca = st.sidebar.text_input("Pesquisar módulo:").strip().lower()
749
+
750
+ try:
751
+ ambiente_atual = current_db_choice() if _HAS_ROUTER else "prod"
752
+ except Exception:
753
+ ambiente_atual = "prod"
754
+
755
+ grupos_disponiveis = obter_grupos_disponiveis(
756
+ MODULES,
757
+ perfil=st.session_state.get("perfil", "usuario"),
758
+ usuario=st.session_state.get("usuario"),
759
+ ambiente=ambiente_atual,
760
+ verificar_permissao=verificar_permissao
761
+ )
762
+
763
+ if not grupos_disponiveis:
764
+ st.sidebar.selectbox("Selecione a operação:", ["Em desenvolvimento"], index=0)
765
+ st.warning("Nenhuma operação disponível para seu perfil/ambiente neste momento.")
766
+ return
767
+
768
+ grupo_escolhido = st.sidebar.selectbox("Selecione a operação:", grupos_disponiveis)
769
+
770
+ opcoes = obter_modulos_para_grupo(
771
+ MODULES, grupo_escolhido, termo_busca,
772
+ perfil=st.session_state.get("perfil", "usuario"),
773
+ usuario=st.session_state.get("usuario"),
774
+ ambiente=ambiente_atual,
775
+ verificar_permissao=verificar_permissao
776
+ )
777
+
778
+ with st.sidebar.expander("🔧 Diagnóstico do menu", expanded=False):
779
+ st.caption(f"Perfil: **{st.session_state.get('perfil', '—')}** | Grupo: **{grupo_escolhido}** | Busca: **{termo_busca or '∅'}** | Ambiente: **{ambiente_atual}**")
780
+ try:
781
+ mods_dbg = [{"id": mid, "label": lbl} for mid, lbl in (opcoes or [])]
782
+ except Exception:
783
+ mods_dbg = []
784
+ st.write("Módulos visíveis (após regras):", mods_dbg if mods_dbg else "—")
785
+
786
+ # Failsafe outlook_relatorio
787
+ try:
788
+ mod_outlook = MODULES.get("outlook_relatorio")
789
+ if mod_outlook:
790
+ mesmo_grupo = (mod_outlook.get("grupo") == grupo_escolhido)
791
+ perfil_ok = verificar_permissao(
792
+ perfil=st.session_state.get("perfil", "usuario"),
793
+ modulo_key="outlook_relatorio",
794
+ usuario=st.session_state.get("usuario"),
795
+ ambiente=ambiente_atual
796
+ )
797
+ ja_nas_opcoes = any(mid == "outlook_relatorio" for mid, _ in (opcoes or []))
798
+ passa_busca = (not termo_busca) or (termo_busca in mod_outlook.get("label", "").strip().lower())
799
+ if mesmo_grupo and perfil_ok and not ja_nas_opcoes and passa_busca:
800
+ opcoes = (opcoes or []) + [("outlook_relatorio", mod_outlook.get("label", "Relatorio portaria"))]
801
+ except Exception:
802
+ pass
803
+
804
+ # Failsafe repositorio_load
805
+ try:
806
+ mod_repo = MODULES.get("repositorio_load")
807
+ if mod_repo:
808
+ mesmo_grupo_r = (mod_repo.get("grupo") == grupo_escolhido)
809
+ perfil_ok_r = verificar_permissao(
810
+ perfil=st.session_state.get("perfil", "usuario"),
811
+ modulo_key="repositorio_load",
812
+ usuario=st.session_state.get("usuario"),
813
+ ambiente=ambiente_atual
814
+ )
815
+ ja_nas_opcoes_r = any(mid == "repositorio_load" for mid, _ in (opcoes or []))
816
+ passa_busca_r = (not termo_busca) or (termo_busca in mod_repo.get("label", "").strip().lower())
817
+ if mesmo_grupo_r and perfil_ok_r and not ja_nas_opcoes_r and passa_busca_r:
818
+ opcoes = (opcoes or []) + [("repositorio_load", mod_repo.get("label", "Repositório Load"))]
819
+ except Exception:
820
+ pass
821
+
822
+ if not opcoes:
823
+ st.sidebar.selectbox("Selecione o módulo:", ["Em desenvolvimento"], index=0)
824
+ st.warning(f"A operação '{grupo_escolhido}' está em desenvolvimento.")
825
+ return
826
+
827
+ # ============================================================
828
+ # 🔒 Fix: selectbox com 'key' + seleção forçada para 'resposta'
829
+ # quando vier de nav_target (sidebar) ou quando já estivermos na página.
830
+ # ============================================================
831
+ labels = [label for _, label in opcoes]
832
+
833
+ # Se foi solicitado nav_target, injeta a label alvo antes do selectbox
834
+ if st.session_state.get("nav_target"):
835
+ target = st.session_state["nav_target"]
836
+ try:
837
+ target_label = next(lbl for mid, lbl in opcoes if mid == target)
838
+ st.session_state["mod_select_label"] = target_label
839
+ except StopIteration:
840
+ pass
841
+
842
+ # Inicializa/persiste seleção
843
+ if "mod_select_label" not in st.session_state or st.session_state["mod_select_label"] not in labels:
844
+ st.session_state["mod_select_label"] = labels[0]
845
+
846
+ escolha_label = st.sidebar.selectbox(
847
+ "Selecione o módulo:",
848
+ labels,
849
+ index=labels.index(st.session_state["mod_select_label"]),
850
+ key="mod_select_label"
851
+ )
852
+
853
+ pagina_id = next(mod_id for mod_id, label in opcoes if label == escolha_label)
854
+
855
+ # ✅ Navegação com lock (evita disputa com outros reruns)
856
+ if st.session_state.get("nav_target"):
857
+ pagina_id = st.session_state.nav_target
858
+ st.session_state["__nav_lock__"] = True
859
+ else:
860
+ st.session_state["__nav_lock__"] = False
861
+
862
+ # 🔎 Agora que sabemos a página atual, tratamos rr=1 com segurança
863
+ _check_rerun_qs(pagina_atual=pagina_id)
864
+
865
+ # ⏱️ Auto-refresh leve do sidebar — NÃO quando em Inbox/Admin/Outlook/Formulário/Recebimento
866
+ try:
867
+ from streamlit_autorefresh import st_autorefresh
868
+ is_inbox_admin = (pagina_id == "resposta")
869
+ is_outlook_rel = (pagina_id == "outlook_relatorio")
870
+ is_formulario = (pagina_id == "formulario")
871
+ is_recebimento = (pagina_id == "recebimento")
872
+ interval_sec = int(st.session_state.get("__auto_refresh_interval_sec__", 60))
873
+ if (interval_sec > 0) and not (is_inbox_admin or is_outlook_rel or is_formulario or is_recebimento):
874
+ # key dinâmica por intervalo evita conflitos ao trocar o valor
875
+ st_autorefresh(interval=interval_sec * 1000, key=f"sidebar_autorefresh_{interval_sec}s")
876
+ except Exception:
877
+ pass
878
+
879
+ # Logout
880
+ st.sidebar.markdown("---")
881
+ if st.session_state.get("logado"):
882
+ if st.sidebar.button("🚪 Sair (Logout)"):
883
+ logout()
884
+
885
+ st.divider()
886
+
887
+ # ------------------------- Roteamento -------------------------
888
+ if pagina_id == "formulario":
889
+ formulario.main()
890
+ elif pagina_id == "consulta":
891
+ consulta.main()
892
+ elif pagina_id == "relatorio":
893
+ relatorio.main()
894
+ elif pagina_id == "ranking":
895
+ ranking.main()
896
+ elif pagina_id == "quiz":
897
+ quiz.main()
898
+ ranking.main()
899
+ elif pagina_id == "quiz_admin":
900
+ quiz_admin.main()
901
+ elif pagina_id == "usuarios":
902
+ usuarios_admin.main()
903
+ elif pagina_id == "administracao":
904
+ administracao.main()
905
+ elif pagina_id == "videos":
906
+ videos.main()
907
+ elif pagina_id == "auditoria":
908
+ auditoria.main()
909
+ elif pagina_id == "auditoria_cleanup":
910
+ auditoria_cleanup.main()
911
+ elif pagina_id == "importacao":
912
+ importar_excel.main()
913
+ elif pagina_id == "calendario":
914
+ calendario.main()
915
+ elif pagina_id == "jogos":
916
+ st.session_state.setdefault("pontuacao", 0)
917
+ st.session_state.setdefault("rodadas", 0)
918
+ st.session_state.setdefault("ultimo_resultado", None)
919
+ jogos.main()
920
+ elif pagina_id == "temporario":
921
+ db_tools.main()
922
+ elif pagina_id == "db_admin":
923
+ db_admin.main()
924
+ elif pagina_id == "db_monitor":
925
+ db_monitor.main()
926
+ elif pagina_id == "operacao":
927
+ operacao.main()
928
+ elif pagina_id == "resposta": # 📬 Admin
929
+ resposta.main()
930
+ elif pagina_id == "db_export_import":
931
+ db_export_import.main()
932
+ elif pagina_id == "produtividade_especialista":
933
+ produtividade_especialista.main()
934
+ elif pagina_id == "outlook_relatorio":
935
+ outlook_relatorio.main()
936
+ elif pagina_id == "sugestoes_ioirun": # 💡 Usuário
937
+ if st.session_state.get("perfil") == "admin":
938
+ st.info("Use a **📬 Caixa de Entrada (Admin)** para responder sugestões.")
939
+ else:
940
+ sugestoes_usuario.main()
941
+ elif pagina_id == "repositorio_load":
942
+ repositorio_load.main()
943
+ elif pagina_id == "rnc":
944
+ rnc.pagina()
945
+ elif pagina_id == "rnc_listagem":
946
+ rnc_listagem.pagina()
947
+ elif pagina_id == "rnc_relatorio":
948
+ rnc_relatorio.pagina()
949
+ elif pagina_id == "repo_rnc":
950
+ repo_rnc.pagina()
951
+ elif pagina_id == "recebimento":
952
+ recebimento.main()
953
+
954
+ # ------------------------------------------------------
955
+ # ℹ️ INFO — Guia passo a passo de uso (no sidebar)
956
+ # ------------------------------------------------------
957
+ info_mod_default = INFO_MAP_PAGINA_ID.get(pagina_id, "Geral")
958
+ with st.sidebar.expander("ℹ️ Info • Como usar o sistema", expanded=False):
959
+ st.markdown("""
960
+ **Bem-vindo!**
961
+ Este painel reúne instruções rápidas para utilizar cada módulo e seus campos.
962
+ Selecione o módulo abaixo ou navegue pelo menu — o conteúdo ajusta automaticamente.
963
+ """)
964
+ mod_info_sel = st.selectbox(
965
+ "Escolha o módulo para ver instruções:",
966
+ INFO_MODULOS,
967
+ index=INFO_MODULOS.index(info_mod_default) if info_mod_default in INFO_MODULOS else 0,
968
+ key="info_mod_sel"
969
+ )
970
+ st.markdown(INFO_CONTEUDO.get(mod_info_sel, "_Conteúdo não disponível para este módulo._"))
971
+
972
+ # ✅ Libera o nav_target após a 1ª render da página de destino
973
+ if st.session_state.get("__nav_lock__"):
974
+ st.session_state["nav_target"] = None
975
+ st.session_state["__nav_lock__"] = False
976
+
977
+ if __name__ == "__main__":
978
+ main()
979
+ # -------------------------
980
+ # Desenvolvedor e versão
981
+ # -------------------------
982
+ if st.session_state.get("logado") and st.session_state.get("email"):
983
+ st.sidebar.markdown(
984
+ f"""
985
+ <div style="display:inline-flex;align-items:center;gap:8px; padding:4px 8px;border-radius:8px;
986
+ background:#e7f1ff;color:#0d6efd;font-size:13px;line-height:1.2;">
987
+ <span style="font-size:16px;">👤</span>
988
+ <span>{st.session_state.email}</span>
989
+ </div>
990
+ """,
991
+ unsafe_allow_html=True
992
+ )
993
+
994
+ st.sidebar.markdown(
995
+ """
996
+ <hr style="margin-top: 10px; margin-bottom: 6px;">
997
+ <p style="font-size: 12px; color: #6c757d;">
998
+ Versão: <strong>1.0.0</strong> • Desenvolvedor: <strong>Rodrigo Silva - Ideiasystem | 2026</strong>
999
+ </p>
1000
+ """,
1001
+ unsafe_allow_html=True
1002
+ )
1003
+
1004
+
1005
+
1006
+
1007
+
1008
+
1009
+
1010
+
1011
+
1012
+
1013
+
1014
+
1015
+
app_outlook.py ADDED
@@ -0,0 +1,315 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ import pandas as pd
5
+ from datetime import datetime, timedelta, date
6
+ import io
7
+ import pythoncom # ✅ necessário para inicializar/finalizar COM em cada operação
8
+
9
+ st.set_page_config(page_title="Relatório de E-mails • Outlook Desktop", layout="wide")
10
+
11
+
12
+ # ==============================
13
+ # Utilitários de exportação/indicadores
14
+ # ==============================
15
+ def build_downloads(df: pd.DataFrame, base_name: str):
16
+ """Cria botões de download (CSV, Excel e PDF) para o DataFrame."""
17
+ if df.empty:
18
+ st.warning("Nenhum dado para exportar.")
19
+ return
20
+
21
+ # CSV
22
+ csv_buf = io.StringIO()
23
+ df.to_csv(csv_buf, index=False, encoding="utf-8-sig")
24
+ st.download_button(
25
+ "⬇️ Baixar CSV",
26
+ data=csv_buf.getvalue(),
27
+ file_name=f"{base_name}.csv",
28
+ mime="text/csv",
29
+ )
30
+
31
+ # Excel
32
+ xlsx_buf = io.BytesIO()
33
+ with pd.ExcelWriter(xlsx_buf, engine="openpyxl") as writer:
34
+ df.to_excel(writer, index=False, sheet_name="Relatorio")
35
+ xlsx_buf.seek(0)
36
+ st.download_button(
37
+ "⬇️ Baixar Excel",
38
+ data=xlsx_buf,
39
+ file_name=f"{base_name}.xlsx",
40
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
41
+ )
42
+
43
+ # PDF (resumo com até 100 linhas para leitura confortável)
44
+ try:
45
+ from reportlab.lib.pagesizes import A4, landscape
46
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
47
+ from reportlab.lib import colors
48
+ from reportlab.lib.styles import getSampleStyleSheet
49
+
50
+ pdf_buf = io.BytesIO()
51
+ doc = SimpleDocTemplate(
52
+ pdf_buf,
53
+ pagesize=landscape(A4),
54
+ rightMargin=20, leftMargin=20, topMargin=20, bottomMargin=20
55
+ )
56
+ styles = getSampleStyleSheet()
57
+ story = []
58
+
59
+ title = Paragraph(f"Relatório de E-mails — {base_name}", styles["Title"])
60
+ story.append(title)
61
+ story.append(Spacer(1, 12))
62
+
63
+ # Limita tabela para evitar PDFs gigantes
64
+ df_show = df.copy().head(100)
65
+ data_table = [list(df_show.columns)] + df_show.astype(str).values.tolist()
66
+ table = Table(data_table, repeatRows=1)
67
+ table.setStyle(TableStyle([
68
+ ("BACKGROUND", (0,0), (-1,0), colors.HexColor("#E9ECEF")),
69
+ ("TEXTCOLOR", (0,0), (-1,0), colors.HexColor("#212529")),
70
+ ("GRID", (0,0), (-1,-1), 0.25, colors.HexColor("#ADB5BD")),
71
+ ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
72
+ ("FONTNAME", (0,1), (-1,-1), "Helvetica"),
73
+ ("FONTSIZE", (0,0), (-1,-1), 9),
74
+ ("ALIGN", (0,0), (-1,-1), "LEFT"),
75
+ ("VALIGN", (0,0), (-1,-1), "MIDDLE"),
76
+ ]))
77
+ story.append(table)
78
+
79
+ doc.build(story)
80
+ pdf_buf.seek(0)
81
+
82
+ st.download_button(
83
+ "⬇️ Baixar PDF",
84
+ data=pdf_buf,
85
+ file_name=f"{base_name}.pdf",
86
+ mime="application/pdf",
87
+ )
88
+ except Exception as e:
89
+ st.info(f"PDF: não foi possível gerar o arquivo (ReportLab). Detalhe: {e}")
90
+
91
+
92
+ def render_indicators(df: pd.DataFrame, dt_col_name: str):
93
+ """Exibe indicadores simples (top remetentes, distribuição por dia)."""
94
+ if df.empty:
95
+ return
96
+ st.subheader("📊 Indicadores")
97
+ col1, col2 = st.columns(2)
98
+ with col1:
99
+ st.write("**Top Remetentes (Top 10)**")
100
+ st.dataframe(
101
+ df["Remetente"].value_counts().head(10).rename("Qtd").to_frame(),
102
+ use_container_width=True,
103
+ )
104
+ with col2:
105
+ st.write("**Mensagens por Dia**")
106
+ if dt_col_name in df.columns:
107
+ _dt = pd.to_datetime(df[dt_col_name], errors="coerce")
108
+ por_dia = _dt.dt.date.value_counts().sort_index().rename("Qtd")
109
+ st.dataframe(por_dia.to_frame(), use_container_width=True)
110
+
111
+
112
+ # ==============================
113
+ # Outlook Desktop (Windows) — COM-safe helpers
114
+ # ==============================
115
+ def _list_folders_desktop(root_folder, prefix=""):
116
+ """Recursão local (já com root_folder pronto) — retorna caminhos completos de subpastas."""
117
+ paths = []
118
+ try:
119
+ for i in range(1, root_folder.Folders.Count + 1):
120
+ f = root_folder.Folders.Item(i)
121
+ full_path = prefix + f.Name
122
+ paths.append(full_path)
123
+ # recursão
124
+ try:
125
+ paths.extend(_list_folders_desktop(f, prefix=full_path + "\\"))
126
+ except Exception:
127
+ pass
128
+ except Exception:
129
+ pass
130
+ return paths
131
+
132
+
133
+ def safe_list_all_folders():
134
+ """
135
+ ✅ Inicializa COM, conecta no Outlook e retorna TODOS os caminhos de pastas
136
+ da caixa padrão. Finaliza COM ao terminar. Evita 'CoInitialize não foi chamado'.
137
+ """
138
+ try:
139
+ import win32com.client
140
+ pythoncom.CoInitialize() # inicializa COM
141
+ outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
142
+ root_mailbox = outlook.Folders.Item(1) # índice da caixa de correio padrão
143
+ return _list_folders_desktop(root_mailbox, prefix="")
144
+ except Exception as e:
145
+ st.sidebar.info(f"Não foi possível listar pastas automaticamente ({e}). Informe manualmente abaixo.")
146
+ return []
147
+ finally:
148
+ try:
149
+ pythoncom.CoUninitialize() # finaliza COM
150
+ except Exception:
151
+ pass
152
+
153
+
154
+ def _get_folder_by_path(root_folder, path: str):
155
+ parts = [p for p in path.split("\\") if p]
156
+ folder = root_folder.Folders.Item(parts[0])
157
+ for p in parts[1:]:
158
+ folder = folder.Folders.Item(p)
159
+ return folder
160
+
161
+
162
+ def _read_folder_items(folder, dias: int, filtro_remetente: str = "") -> pd.DataFrame:
163
+ """Lê e-mails de uma pasta específica e retorna DataFrame."""
164
+ items = folder.Items
165
+ items.Sort("[ReceivedTime]", True) # decrescente
166
+ dt_from = (datetime.now() - timedelta(days=dias)).strftime("%m/%d/%Y %H:%M %p")
167
+ try:
168
+ items = items.Restrict(f"[ReceivedTime] >= '{dt_from}'")
169
+ except Exception:
170
+ # Alguns ambientes podem falhar no Restrict; segue sem filtro temporal
171
+ pass
172
+
173
+ rows = []
174
+ for mail in items:
175
+ try:
176
+ if getattr(mail, "Class", None) != 43: # 43 = MailItem
177
+ continue
178
+ try:
179
+ sender = mail.SenderEmailAddress or mail.Sender.Name
180
+ except Exception:
181
+ sender = getattr(mail, "SenderName", None)
182
+
183
+ # Filtro opcional por remetente
184
+ if filtro_remetente and sender:
185
+ if filtro_remetente.lower() not in str(sender).lower():
186
+ continue
187
+
188
+ anexos = mail.Attachments.Count if hasattr(mail, "Attachments") else 0
189
+ tamanho_kb = round(mail.Size / 1024, 1) if hasattr(mail, "Size") else None
190
+
191
+ rows.append({
192
+ "Pasta": folder.Name,
193
+ "Assunto": mail.Subject,
194
+ "Remetente": sender,
195
+ "RecebidoEm": mail.ReceivedTime.strftime("%Y-%m-%d %H:%M"),
196
+ "Anexos": anexos,
197
+ "TamanhoKB": tamanho_kb,
198
+ "Importancia": str(getattr(mail, "Importance", "")), # 0 baixa, 1 normal, 2 alta
199
+ "Categoria": getattr(mail, "Categories", "") or "",
200
+ "Lido": bool(getattr(mail, "UnRead", False) == False),
201
+ })
202
+ except Exception as e:
203
+ rows.append({
204
+ "Pasta": folder.Name, "Assunto": f"[ERRO] {e}", "Remetente": "",
205
+ "RecebidoEm": "", "Anexos": "", "TamanhoKB": "", "Importancia": "", "Categoria": "", "Lido": ""
206
+ })
207
+ return pd.DataFrame(rows)
208
+
209
+
210
+ def gerar_relatorio_outlook_desktop_multi(pastas: list[str], dias: int, filtro_remetente: str = "") -> pd.DataFrame:
211
+ """
212
+ ✅ Envolve toda operação COM: inicializa, lê e finaliza.
213
+ Evita o erro 'CoInitialize não foi chamado.'
214
+ """
215
+ try:
216
+ import win32com.client
217
+ pythoncom.CoInitialize() # inicializa COM
218
+ outlook = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
219
+ root = outlook.Folders.Item(1) # ajuste o índice se tiver múltiplas caixas
220
+ except Exception as e:
221
+ st.error(f"Falha ao conectar ao Outlook/pywin32: {e}")
222
+ return pd.DataFrame()
223
+
224
+ frames = []
225
+ try:
226
+ for path in pastas:
227
+ try:
228
+ folder = _get_folder_by_path(root, path)
229
+ df = _read_folder_items(folder, dias, filtro_remetente=filtro_remetente)
230
+ df["PastaPath"] = path
231
+ frames.append(df)
232
+ except Exception as e:
233
+ st.warning(f"Não foi possível ler a pasta '{path}': {e}")
234
+ return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
235
+ finally:
236
+ try:
237
+ pythoncom.CoUninitialize() # finaliza COM
238
+ except Exception:
239
+ pass
240
+
241
+
242
+ # ==============================
243
+ # UI — Streamlit (seleção de múltiplas pastas)
244
+ # ==============================
245
+ st.title("📧 Relatório de E-mails • Outlook Desktop (Windows)")
246
+ 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.")
247
+
248
+ st.sidebar.header("Configurações")
249
+ dias = st.sidebar.slider("Período (últimos N dias)", min_value=1, max_value=365, value=30)
250
+ filtro_remetente = st.sidebar.text_input(
251
+ "Filtrar por remetente (opcional)",
252
+ value="",
253
+ placeholder='Ex.: "@fornecedor.com" ou "Fulano"'
254
+ )
255
+ apenas_inbox = st.sidebar.checkbox("Mostrar somente pastas sob Inbox", value=True)
256
+
257
+ # Tentar listar todas as pastas (COM-safe)
258
+ todas_pastas = safe_list_all_folders()
259
+
260
+ # Filtrar apenas pastas sob Inbox (Caixa de Entrada), se marcado
261
+ if todas_pastas:
262
+ if apenas_inbox:
263
+ opcoes_base = [p for p in todas_pastas if p.lower().startswith("inbox")]
264
+ else:
265
+ opcoes_base = todas_pastas
266
+ else:
267
+ opcoes_base = []
268
+
269
+ # Busca por nome
270
+ filtro_pasta = st.sidebar.text_input("Pesquisar pasta por nome:", value="")
271
+ if filtro_pasta and opcoes_base:
272
+ opcoes = [p for p in opcoes_base if filtro_pasta.lower() in p.lower()]
273
+ else:
274
+ opcoes = opcoes_base or []
275
+
276
+ # Multiselect de pastas
277
+ pastas_escolhidas = st.sidebar.multiselect(
278
+ "Selecione uma ou mais pastas:",
279
+ options=opcoes if opcoes else ["Inbox"],
280
+ default=(opcoes[:1] if opcoes else ["Inbox"]),
281
+ help="Use '\\' para subpastas. Ex.: Inbox\\Financeiro\\Notas"
282
+ )
283
+
284
+ # Campo manual adicional (para quem quer escrever um caminho específico não listado)
285
+ pasta_manual_extra = st.sidebar.text_input(
286
+ "Adicionar caminho manual (opcional)",
287
+ value="",
288
+ placeholder="Inbox\\Financeiro\\Notas"
289
+ )
290
+ if pasta_manual_extra.strip():
291
+ pastas_escolhidas = list(set(pastas_escolhidas + [pasta_manual_extra.strip()]))
292
+
293
+ # Botão gerar
294
+ if st.sidebar.button("🔍 Gerar relatório"):
295
+ if not pastas_escolhidas:
296
+ st.error("Selecione ao menos uma pasta.")
297
+ else:
298
+ with st.spinner("Lendo e-mails do Outlook..."):
299
+ df = gerar_relatorio_outlook_desktop_multi(
300
+ pastas_escolhidas,
301
+ dias,
302
+ filtro_remetente=filtro_remetente
303
+ )
304
+
305
+ st.success(f"Relatório gerado ({len(df)} registros) a partir de {len(pastas_escolhidas)} pasta(s).")
306
+
307
+ st.subheader("📄 Resultado")
308
+ st.dataframe(df, use_container_width=True)
309
+ render_indicators(df, dt_col_name="RecebidoEm")
310
+
311
+ base_name = f"relatorio_outlook_desktop_{date.today()}"
312
+ build_downloads(df, base_name=base_name)
313
+
314
+ st.markdown("---")
315
+ st.caption("Dica: se você tem várias caixas postais, troque o índice em `outlook.Folders.Item(1)` para a caixa correta.")
audit_streamlit_project.py ADDED
@@ -0,0 +1,512 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ Auditor de projeto Streamlit — chaves duplicadas, estrutura e relacionamentos.
5
+
6
+ Verifica:
7
+ 1) Chaves duplicadas em st.form/st.button/st.download_button.
8
+ 2) Widgets sem 'key' (risco em loops).
9
+ 3) Imports faltantes no app.py para módulos usados no roteamento.
10
+ 4) Cobertura MODULES ↔ Roteamento (entries sem rota e rotas sem entry).
11
+ 5) Arquivos de módulos inexistentes e módulos sem main().
12
+ 6) Imports não usados.
13
+ 7) Ciclos de importação entre arquivos .py (somente locais).
14
+ 8) Emite relatório em console e JSON.
15
+
16
+ Uso:
17
+ python audit_streamlit_project.py
18
+ python audit_streamlit_project.py --root . --app app.py --modules modules_map.py --exclude venv .venv .git
19
+
20
+ Saída JSON:
21
+ .audit_report.json (na raiz especificada)
22
+ """
23
+ import os
24
+ import re
25
+ import ast
26
+ import json
27
+ import argparse
28
+ from collections import defaultdict
29
+
30
+ # -----------------------
31
+ # Util — File discovery
32
+ # -----------------------
33
+ def find_python_files(root, exclude_dirs=None):
34
+ exclude_dirs = set(exclude_dirs or [])
35
+ for dirpath, dirnames, filenames in os.walk(root):
36
+ # filtra diretorios ignorados
37
+ dirnames[:] = [
38
+ d for d in dirnames
39
+ if os.path.join(dirpath, d) not in {os.path.join(root, ex) for ex in exclude_dirs}
40
+ and d not in exclude_dirs
41
+ ]
42
+ for fn in filenames:
43
+ if fn.endswith(".py"):
44
+ yield os.path.join(dirpath, fn)
45
+
46
+ def read_text(path):
47
+ try:
48
+ with open(path, "r", encoding="utf-8") as f:
49
+ return f.read()
50
+ except Exception:
51
+ try:
52
+ with open(path, "r", encoding="latin-1") as f:
53
+ return f.read()
54
+ except Exception:
55
+ return ""
56
+
57
+ def parse_ast(path):
58
+ src = read_text(path)
59
+ if not src:
60
+ return None, ""
61
+ try:
62
+ tree = ast.parse(src, filename=path)
63
+ return tree, src
64
+ except Exception:
65
+ return None, src
66
+
67
+ # -----------------------
68
+ # Scan — Streamlit keys
69
+ # -----------------------
70
+ KEY_PATTERNS = {
71
+ "form_literal": re.compile(r'st\.form\(\s*\'"[\'"]'),
72
+ "button_key": re.compile(r'st\.button\([^)]*key\s*=\s*\'"[\'"]'),
73
+ "download_key": re.compile(r'st\.download_button\([^)]*key\s*=\s*\'"[\'"]'),
74
+ }
75
+ # widgets sem key (para alertar)
76
+ MISSING_KEY_PATTERNS = {
77
+ "button_no_key": re.compile(r'st\.button\((?![^)]*key\s*=)'),
78
+ "download_no_key": re.compile(r'st\.download_button\((?![^)]*key\s*=)'),
79
+ }
80
+
81
+ def scan_duplicate_and_missing_keys(file_path):
82
+ dups = defaultdict(list)
83
+ missing = defaultdict(list)
84
+ try:
85
+ with open(file_path, "r", encoding="utf-8") as f:
86
+ for i, line in enumerate(f, 1):
87
+ # dup keys
88
+ for _, pat in KEY_PATTERNS.items():
89
+ for m in pat.finditer(line):
90
+ dups[m.group(1)].append(i)
91
+ # missing key warnings
92
+ for name, pat in MISSING_KEY_PATTERNS.items():
93
+ if pat.search(line):
94
+ missing[name].append(i)
95
+ except Exception:
96
+ pass
97
+ dup_filtered = {k: v for k, v in dups.items() if len(v) > 1}
98
+ return dup_filtered, missing
99
+
100
+ # -----------------------
101
+ # AST helpers — imports
102
+ # -----------------------
103
+ def extract_imports_defs_calls(tree):
104
+ """
105
+ Retorna:
106
+ imports: { alias_ou_nome -> modulo_base }
107
+ used_names: set de nomes referenciados
108
+ defs: set de nomes de funções definidas
109
+ calls_main: set de nomes/lvalues em chamadas *.main()
110
+ """
111
+ imports = {} # alias -> base_module
112
+ used_names = set()
113
+ defs = set()
114
+ calls_main = set()
115
+
116
+ class V(ast.NodeVisitor):
117
+ def visit_Import(self, node):
118
+ for alias in node.names:
119
+ base = alias.name.split(".")[0]
120
+ asname = alias.asname or alias.name
121
+ asname = asname.split(".")[0]
122
+ imports[asname] = base
123
+
124
+ def visit_ImportFrom(self, node):
125
+ if node.module:
126
+ base = node.module.split(".")[0]
127
+ for alias in node.names:
128
+ asname = alias.asname or alias.name
129
+ imports[asname] = base
130
+
131
+ def visit_FunctionDef(self, node):
132
+ defs.add(node.name)
133
+ self.generic_visit(node)
134
+
135
+ def visit_Name(self, node):
136
+ used_names.add(node.id)
137
+
138
+ def visit_Attribute(self, node):
139
+ # captura padrão X.main(...)
140
+ if isinstance(node.ctx, ast.Load) and getattr(node, "attr", None) == "main":
141
+ if isinstance(node.value, ast.Name):
142
+ calls_main.add(node.value.id)
143
+ else:
144
+ # pkg.sub.main -> tenta achar o nome raiz
145
+ root = node.value
146
+ while isinstance(root, ast.Attribute):
147
+ root = root.value
148
+ if isinstance(root, ast.Name):
149
+ calls_main.add(root.id)
150
+ self.generic_visit(node)
151
+
152
+ if tree:
153
+ V().visit(tree)
154
+ return imports, used_names, defs, calls_main
155
+
156
+ # -----------------------
157
+ # modules_map.py — parse
158
+ # -----------------------
159
+ def load_modules_map(modules_map_path):
160
+ """
161
+ Extrai:
162
+ - route_keys: chaves top-level do dict MODULES (ex.: "consulta", "operacao"...)
163
+ - internal_keys: valores do campo "key" dentro de cada entrada
164
+ """
165
+ route_keys = set()
166
+ internal_keys = set()
167
+ src = read_text(modules_map_path)
168
+ if not src:
169
+ return route_keys, internal_keys
170
+ # chaves top-level (aproximação): linhas com " \"nome\": {"
171
+ for m in re.finditer(r'^[ \t]*"([^"]+)"\s*:\s*\{', src, re.MULTILINE):
172
+ route_keys.add(m.group(1))
173
+ # field "key": "valor"
174
+ for m in re.finditer(r'"key"\s*:\s*"([^"]+)"', src):
175
+ internal_keys.add(m.group(1))
176
+ return route_keys, internal_keys
177
+
178
+ # -----------------------
179
+ # Roteamento em app.py
180
+ # -----------------------
181
+ def extract_routing(app_src):
182
+ """
183
+ Busca padrões:
184
+ if/elif pagina_id == "consulta":
185
+ consulta.main()
186
+ Retorna lista de tuplas: (route_key, called_module_name)
187
+ """
188
+ routes = []
189
+
190
+ # bloco "if" inicial
191
+ m_if = re.search(
192
+ r'if\s+pagina_id\s*==\s*\'"[\'"]\s*:\s*(.*?)\n\s*(?:elif|#|$)',
193
+ app_src, re.DOTALL
194
+ )
195
+ if m_if:
196
+ route = m_if.group(1)
197
+ block = m_if.group(2)
198
+ called = None
199
+ cm = re.search(r'([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*main\s*\(', block)
200
+ if cm:
201
+ called = cm.group(1)
202
+ routes.append((route, called))
203
+
204
+ # blocos "elif"
205
+ for m in re.finditer(
206
+ r'elif\s+pagina_id\s*==\s*\'"[\'"]\s*:\s*(.*?)\n\s*(?:elif|#|$)',
207
+ app_src, re.DOTALL
208
+ ):
209
+ route = m.group(1)
210
+ block = m.group(2)
211
+ called = None
212
+ cm = re.search(r'([A-Za-z_][A-Za-z0-9_]*)\s*\.\s*main\s*\(', block)
213
+ if cm:
214
+ called = cm.group(1)
215
+ routes.append((route, called))
216
+
217
+ return routes
218
+
219
+ # -----------------------
220
+ # Import graph & cycles
221
+ # -----------------------
222
+ def build_local_import_graph(py_files):
223
+ """
224
+ Monta grafo de importações locais: base_name -> { base_names importados }
225
+ """
226
+ # mapeia base_name -> arquivo
227
+ base_to_file = {}
228
+ for f in py_files:
229
+ base = os.path.splitext(os.path.basename(f))[0]
230
+ base_to_file[base] = f
231
+
232
+ graph = defaultdict(set)
233
+ for f in py_files:
234
+ base = os.path.splitext(os.path.basename(f))[0]
235
+ tree, _ = parse_ast(f)
236
+ imports, _, _, _ = extract_imports_defs_calls(tree)
237
+ for alias, base_mod in imports.items():
238
+ # se alias ou base_mod mapeia para arquivo local, considera aresta
239
+ target = None
240
+ if alias in base_to_file:
241
+ target = alias
242
+ elif base_mod in base_to_file:
243
+ target = base_mod
244
+ if target and target != base:
245
+ graph[base].add(target)
246
+ return graph
247
+
248
+ def find_cycles(graph):
249
+ """
250
+ Detecta ciclos no grafo (lista de ciclos) — sem mutar o dicionário durante a iteração.
251
+ """
252
+ # Conjunto estático de nós (origens + destinos)
253
+ nodes = set(graph.keys())
254
+ for vs in graph.values():
255
+ nodes.update(vs)
256
+
257
+ visited = set()
258
+ stack = set()
259
+ cycles = []
260
+ path = []
261
+
262
+ def dfs(u):
263
+ visited.add(u)
264
+ stack.add(u)
265
+ path.append(u)
266
+ for v in graph.get(u, set()): # <- sem criar chaves novas
267
+ if v not in visited:
268
+ dfs(v)
269
+ elif v in stack:
270
+ # ciclo encontrado — extrai subpath (v até fim) + fecha em v
271
+ if v in path:
272
+ idx = len(path) - 1
273
+ while idx >= 0 and path[idx] != v:
274
+ idx -= 1
275
+ if idx >= 0:
276
+ cycle = path[idx:] + [v]
277
+ cycles.append(cycle)
278
+ stack.remove(u)
279
+ path.pop()
280
+
281
+ for node in list(nodes): # <- lista estática
282
+ if node not in visited:
283
+ dfs(node)
284
+
285
+ # Deduplicar ciclos por forma canônica (rotação mínima)
286
+ def canonical(cyc):
287
+ core = cyc[:-1] # remove a repetição final
288
+ if not core:
289
+ return tuple()
290
+ rots = [tuple(core[i:] + core[:i]) for i in range(len(core))]
291
+ return min(rots)
292
+
293
+ seen = set()
294
+ unique = []
295
+ for cyc in cycles:
296
+ can = canonical(cyc)
297
+ if can and can not in seen:
298
+ seen.add(can)
299
+ unique.append(cyc)
300
+ return unique
301
+
302
+ # -----------------------
303
+ # Unused imports (aprox)
304
+ # -----------------------
305
+ def find_unused_imports(tree, imports, used_names):
306
+ """
307
+ Aproximação: se o alias importado não aparece em used_names -> não usado.
308
+ Não detecta usos por getattr/reflection; serve como guia inicial.
309
+ """
310
+ unused = []
311
+ for alias in imports.keys():
312
+ if alias not in used_names:
313
+ unused.append(alias)
314
+ return unused
315
+
316
+ # -----------------------
317
+ # Auditor principal
318
+ # -----------------------
319
+ def audit(root, app_path, modules_map_path, exclude_dirs=None, output_json=".audit_report.json"):
320
+ report = {
321
+ "duplicate_keys": {}, # file -> {key: [lines]}
322
+ "widgets_without_key": {}, # file -> {pattern: [lines]}
323
+ "missing_imports_in_app": [], # [(route_key, called_module, reason)]
324
+ "routing_vs_modules": {
325
+ "routes_without_modules_entry": [], # [route_key]
326
+ "modules_entry_without_route": [], # [modules_map_key]
327
+ },
328
+ "module_files_missing": [], # [module_name]
329
+ "modules_without_main": [], # [module_name]
330
+ "unused_imports": {}, # file -> [alias]
331
+ "import_cycles": [], # [[mod_a, mod_b, ..., mod_a]]
332
+ }
333
+
334
+ # 1) varrer arquivos
335
+ py_files = list(find_python_files(root, exclude_dirs=exclude_dirs))
336
+ # mapa base_name -> file
337
+ base_to_file = {os.path.splitext(os.path.basename(f))[0]: f for f in py_files}
338
+
339
+ # 2) chaves duplicadas e widgets sem key
340
+ for f in py_files:
341
+ dups, missing = scan_duplicate_and_missing_keys(f)
342
+ if dups:
343
+ report["duplicate_keys"][f] = dups
344
+ if any(missing.values()):
345
+ report["widgets_without_key"][f] = {k: v for k, v in missing.items() if v}
346
+
347
+ # 3) carrega app.py e modules_map.py
348
+ app_full = os.path.join(root, app_path)
349
+ modules_map_full = os.path.join(root, modules_map_path)
350
+ app_tree, app_src = parse_ast(app_full)
351
+ routes = extract_routing(app_src) if app_src else []
352
+
353
+ # imports e defs do app
354
+ app_imports, app_used, app_defs, app_calls_main = extract_imports_defs_calls(app_tree)
355
+
356
+ # 4) MODULES
357
+ route_keys_in_map, internal_keys_in_map = load_modules_map(modules_map_full)
358
+
359
+ # 5) checar import para cada rota
360
+ routes_set = set()
361
+ for route_key, called_module in routes:
362
+ routes_set.add(route_key)
363
+ if not called_module:
364
+ report["missing_imports_in_app"].append((route_key, None, "Bloco da rota não chama *.main()"))
365
+ continue
366
+ # foi importado?
367
+ imported_aliases = set(app_imports.keys()) # aliases disponíveis
368
+ if called_module not in imported_aliases:
369
+ report["missing_imports_in_app"].append((route_key, called_module, "Módulo não importado no app.py"))
370
+ # arquivo existe?
371
+ if called_module not in base_to_file:
372
+ # talvez seja alias de import (base module)
373
+ base_mod = app_imports.get(called_module)
374
+ if not (base_mod and base_mod in base_to_file):
375
+ report["module_files_missing"].append(called_module)
376
+ else:
377
+ # checar main()
378
+ t, _ = parse_ast(base_to_file[called_module])
379
+ _, _, defs, _ = extract_imports_defs_calls(t)
380
+ if "main" not in defs:
381
+ report["modules_without_main"].append(called_module)
382
+
383
+ # 6) cobertura rota vs modules_map
384
+ # - rotas no app que não existem no modules_map
385
+ for r in routes_set:
386
+ if r not in route_keys_in_map and r not in internal_keys_in_map:
387
+ report["routing_vs_modules"]["routes_without_modules_entry"].append(r)
388
+ # - entries no modules_map que não têm rota no app
389
+ for m in route_keys_in_map:
390
+ if m not in routes_set:
391
+ report["routing_vs_modules"]["modules_entry_without_route"].append(m)
392
+
393
+ # 7) unused imports por arquivo
394
+ for f in py_files:
395
+ t, _ = parse_ast(f)
396
+ imp, used, defs, calls_main = extract_imports_defs_calls(t)
397
+ unused = find_unused_imports(t, imp, used)
398
+ if unused:
399
+ report["unused_imports"][f] = unused
400
+
401
+ # 8) ciclos de import local
402
+ graph = build_local_import_graph(py_files)
403
+ cycles = find_cycles(graph)
404
+ report["import_cycles"] = cycles
405
+
406
+ # 9) remover duplicidades simples nas listas
407
+ report["missing_imports_in_app"] = list(dict.fromkeys(report["missing_imports_in_app"]))
408
+ report["module_files_missing"] = sorted(set(report["module_files_missing"]))
409
+ report["modules_without_main"] = sorted(set(report["modules_without_main"]))
410
+ report["routing_vs_modules"]["routes_without_modules_entry"] = sorted(
411
+ set(report["routing_vs_modules"]["routes_without_modules_entry"]))
412
+ report["routing_vs_modules"]["modules_entry_without_route"] = sorted(
413
+ set(report["routing_vs_modules"]["modules_entry_without_route"]))
414
+
415
+ # 10) saída
416
+ print("\n=== RELATÓRIO DE AUDITORIA — Streamlit Project ===")
417
+ # chaves duplicadas
418
+ print("\n[Chaves duplicadas]")
419
+ if not report["duplicate_keys"]:
420
+ print(" ✔ Nenhuma chave duplicada literal encontrada.")
421
+ else:
422
+ for file, dups in report["duplicate_keys"].items():
423
+ print(f" - {file}")
424
+ for key, lines in dups.items():
425
+ print(f" * key='{key}' duplicada em linhas {lines}")
426
+
427
+ # widgets sem key
428
+ print("\n[Widgets sem 'key' (atenção em loops)]")
429
+ if not report["widgets_without_key"]:
430
+ print(" ✔ Nenhum potencial widget sem key encontrado.")
431
+ else:
432
+ for file, miss in report["widgets_without_key"].items():
433
+ print(f" - {file}")
434
+ for kind, lines in miss.items():
435
+ print(f" * {kind}: linhas {lines}")
436
+
437
+ # imports faltantes e módulos
438
+ print("\n[Imports faltantes no app e módulos]")
439
+ if not report["missing_imports_in_app"]:
440
+ print(" ✔ Nenhum import faltante detectado no app.py (para rotas).")
441
+ else:
442
+ for route_key, called_module, reason in report["missing_imports_in_app"]:
443
+ print(f" - rota='{route_key}' -> módulo='{called_module}' • {reason}")
444
+ if not report["module_files_missing"]:
445
+ print(" ✔ Nenhum arquivo de módulo ausente detectado.")
446
+ else:
447
+ print(" Arquivos de módulo não encontrados:", report["module_files_missing"])
448
+ if not report["modules_without_main"]:
449
+ print(" ✔ Todos os módulos localizados possuem main().")
450
+ else:
451
+ print(" Módulos sem main():", report["modules_without_main"])
452
+
453
+ # cobertura MODULES ↔ Roteamento
454
+ print("\n[Consistência: MODULES x Roteamento]")
455
+ rwm = report["routing_vs_modules"]
456
+ if not rwm["routes_without_modules_entry"]:
457
+ print(" ✔ Todas as rotas possuem entrada em modules_map.py (ou 'key' interna).")
458
+ else:
459
+ print(" Rotas sem entrada no modules_map.py:", rwm["routes_without_modules_entry"])
460
+ if not rwm["modules_entry_without_route"]:
461
+ print(" ✔ Todas as entradas do modules_map.py possuem rota no app.py.")
462
+ else:
463
+ print(" Entradas do modules_map.py sem rota no app.py:", rwm["modules_entry_without_route"])
464
+
465
+ # imports não usados
466
+ print("\n[Imports não usados (aprox.)]")
467
+ if not report["unused_imports"]:
468
+ print(" ✔ Nenhum import potencialmente não usado encontrado.")
469
+ else:
470
+ for file, unused in report["unused_imports"].items():
471
+ print(f" - {file}: {unused}")
472
+
473
+ # ciclos
474
+ print("\n[Ciclos de importação]")
475
+ if not report["import_cycles"]:
476
+ print(" ✔ Nenhum ciclo de importação detectado.")
477
+ else:
478
+ for cyc in report["import_cycles"]:
479
+ print(" - ciclo:", " -> ".join(cyc))
480
+
481
+ # salvar JSON
482
+ out_path = os.path.join(root, output_json)
483
+ with open(out_path, "w", encoding="utf-8") as f:
484
+ json.dump(report, f, ensure_ascii=False, indent=2)
485
+ print(f"\n📄 Relatório JSON salvo em: {out_path}")
486
+
487
+ return report
488
+
489
+ # -----------------------
490
+ # CLI
491
+ # -----------------------
492
+ def cli():
493
+ p = argparse.ArgumentParser(description="Auditor de projeto Streamlit")
494
+ p.add_argument("--root", default=".", help="Raiz do projeto (default: .)")
495
+ p.add_argument("--app", default="app.py", help="Caminho do app.py (relativo à raiz)")
496
+ p.add_argument("--modules", default="modules_map.py", help="Caminho do modules_map.py (relativo à raiz)")
497
+ p.add_argument("--exclude", nargs="*", default=[".git", ".venv", "venv", "__pycache__", ".streamlit"],
498
+ help="Pastas a excluir da varredura")
499
+ p.add_argument("--json", default=".audit_report.json", help="Nome do arquivo JSON de saída")
500
+ args = p.parse_args()
501
+
502
+ audit(
503
+ root=args.root,
504
+ app_path=args.app,
505
+ modules_map_path=args.modules,
506
+ exclude_dirs=args.exclude,
507
+ output_json=args.json
508
+ )
509
+
510
+ if __name__ == "__main__":
511
+ cli()
512
+
auditoria.py ADDED
@@ -0,0 +1,100 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import streamlit as st
3
+ from banco import SessionLocal
4
+ from models import LogAcesso, Usuario
5
+ import pandas as pd
6
+ from io import BytesIO
7
+ import os
8
+
9
+ # Debug opcional – confirma o banco em uso
10
+ print("📂 BANCO LIDO NA AUDITORIA:", os.path.abspath("load.db"))
11
+
12
+
13
+ def main():
14
+ st.title("🧾 Auditoria do Sistema Load")
15
+
16
+ db = SessionLocal()
17
+
18
+ try:
19
+ # =========================
20
+ # FILTRO POR PERFIL
21
+ # =========================
22
+ perfis = (
23
+ db.query(Usuario.perfil)
24
+ .distinct()
25
+ .order_by(Usuario.perfil)
26
+ .all()
27
+ )
28
+
29
+ lista_perfis = ["Todos"] + [p[0] for p in perfis]
30
+
31
+ perfil_selecionado = st.selectbox(
32
+ "Filtrar por perfil:",
33
+ lista_perfis
34
+ )
35
+
36
+ # =========================
37
+ # CONSULTA COM JOIN
38
+ # =========================
39
+ # ✅ Incluímos o e-mail do usuário na seleção
40
+ query = (
41
+ db.query(
42
+ LogAcesso.usuario,
43
+ Usuario.perfil,
44
+ Usuario.email, # <-- novo
45
+ LogAcesso.acao,
46
+ LogAcesso.tabela,
47
+ LogAcesso.registro_id,
48
+ LogAcesso.data_hora
49
+ )
50
+ .join(Usuario, Usuario.usuario == LogAcesso.usuario)
51
+ .order_by(LogAcesso.data_hora.desc())
52
+ )
53
+
54
+ if perfil_selecionado != "Todos":
55
+ query = query.filter(Usuario.perfil == perfil_selecionado)
56
+
57
+ logs = query.all()
58
+
59
+ if not logs:
60
+ st.info("Nenhum registro encontrado.")
61
+ return
62
+
63
+ # =========================
64
+ # DATAFRAME FORMATADO
65
+ # =========================
66
+ dados = []
67
+ for l in logs:
68
+ # l = (usuario, perfil, email, acao, tabela, registro_id, data_hora)
69
+ dados.append({
70
+ "Usuário": l[0],
71
+ "Perfil": l[1],
72
+ "E-mail": l[2] or "—", # ✅ e-mail pode ser nulo
73
+ "Ação": l[3],
74
+ "Tabela": l[4],
75
+ "Registro": l[5],
76
+ "Data": l[6].strftime("%d/%m/%Y"),
77
+ "Hora": l[6].strftime("%H:%M:%S"),
78
+ })
79
+
80
+ df = pd.DataFrame(dados)
81
+
82
+ st.dataframe(df, use_container_width=True)
83
+
84
+ # =========================
85
+ # EXPORTAÇÃO PARA EXCEL
86
+ # =========================
87
+ buffer = BytesIO()
88
+ with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
89
+ df.to_excel(writer, index=False, sheet_name="Auditoria")
90
+
91
+ st.download_button(
92
+ label="📥 Exportar Auditoria para Excel",
93
+ data=buffer.getvalue(),
94
+ file_name="auditoria_sistema.xlsx",
95
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
96
+ )
97
+
98
+ finally:
99
+ db.close()
100
+
auditoria_cleanup.py ADDED
@@ -0,0 +1,103 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import streamlit as st
3
+ from datetime import datetime, timedelta
4
+ from banco import SessionLocal
5
+ from models import LogAcesso # ✅ usar a tabela correta
6
+ from utils_auditoria import registrar_log
7
+
8
+
9
+ def main():
10
+ st.title("🧹 Limpeza de Logs de Auditoria")
11
+
12
+ st.markdown(
13
+ """
14
+ Este módulo permite excluir registros antigos de auditoria
15
+ para suavizar o banco de dados.
16
+ """
17
+ )
18
+
19
+ opcoes = {
20
+ "Último mês": 30,
21
+ "Últimos 2 meses": 60,
22
+ "Últimos 6 meses": 180,
23
+ "Últimos 12 meses": 365,
24
+ "Personalizado": None,
25
+ }
26
+
27
+ escolha = st.selectbox("📅 Escolha o período:", list(opcoes.keys()))
28
+
29
+ data_inicio = None
30
+ data_fim = datetime.now()
31
+
32
+ if escolha != "Personalizado":
33
+ dias = opcoes[escolha]
34
+ data_inicio = datetime.now() - timedelta(days=dias)
35
+ else:
36
+ col1, col2 = st.columns(2)
37
+ with col1:
38
+ data_inicio = st.date_input("Data inicial")
39
+ with col2:
40
+ data_fim = st.date_input("Data final")
41
+ data_inicio = datetime.combine(data_inicio, datetime.min.time())
42
+ data_fim = datetime.combine(data_fim, datetime.max.time())
43
+
44
+ st.info(f"🗓️ Registros de auditoria entre {data_inicio.date()} e {data_fim.date()} serão excluídos.")
45
+
46
+ st.divider()
47
+
48
+ # ✅ Prévia do total (opcional, ajuda na decisão)
49
+ with SessionLocal() as db:
50
+ total_prev = (
51
+ db.query(LogAcesso)
52
+ .filter(LogAcesso.data_hora >= data_inicio, LogAcesso.data_hora <= data_fim)
53
+ .count()
54
+ )
55
+ st.info(f"🔎 Prévia: {total_prev} registro(s) serão removidos no período selecionado.")
56
+
57
+ # ✅ Etapa de confirmação via caixa de seleção
58
+ st.warning(
59
+ "⚠️ **Atenção:** Todos os registros de auditoria no período selecionado serão apagados.\n\n"
60
+ "Confirme abaixo para prosseguir."
61
+ )
62
+ confirmacao = st.selectbox("Confirmar exclusão?", ["Não", "SIM"], index=0)
63
+
64
+ # Botão de exclusão (só prossegue se confirmação for SIM)
65
+ if st.button("❌ Excluir registros de auditoria"):
66
+ if confirmacao != "SIM":
67
+ st.error("Operação cancelada. Se desejar prosseguir, selecione **SIM** na confirmação.")
68
+ return
69
+
70
+ with SessionLocal() as db:
71
+ try:
72
+ registros = (
73
+ db.query(LogAcesso)
74
+ .filter(LogAcesso.data_hora >= data_inicio, LogAcesso.data_hora <= data_fim)
75
+ .all()
76
+ )
77
+
78
+ total = len(registros)
79
+
80
+ if total == 0:
81
+ st.warning("Nenhum registro encontrado para exclusão.")
82
+ return
83
+
84
+ for r in registros:
85
+ db.delete(r)
86
+
87
+ db.commit()
88
+
89
+ registrar_log(
90
+ usuario=st.session_state.get("usuario"),
91
+ acao=f"Excluiu {total} registros de auditoria entre {data_inicio.date()} e {data_fim.date()}",
92
+ tabela="log_acesso",
93
+ registro_id=None
94
+ )
95
+
96
+ st.success(f"🎉 {total} registro(s) de auditoria foram excluídos com sucesso!")
97
+
98
+ except Exception as e:
99
+ db.rollback()
100
+ st.error(f"❌ Erro ao excluir registros: {e}")
101
+
102
+
103
+
auto_capture.py ADDED
@@ -0,0 +1,319 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ auto_capture.py — Captura screenshots de todas as telas do app Streamlit e monta um PPTX.
5
+
6
+ Recursos:
7
+ • Login automático (usuário/senha + escolha do banco)
8
+ • Bypass do Quiz (clica: “Voltar ao sistema”, “Finalizar”, “Continuar”, se visível)
9
+ • Seletores robustos para st.selectbox (procura pelo label visível)
10
+ • Captura pós-login/pós-quiz, por grupo e por módulo
11
+ • Artefatos de debug (HTML + PNG) quando algo falha
12
+ • Sanitização de nomes de arquivo (compatível com Windows)
13
+ • Geração de PPTX com um slide por módulo capturado
14
+
15
+ Requisitos:
16
+ pip install playwright python-pptx python-dotenv
17
+ playwright install
18
+ """
19
+
20
+ import os
21
+ import re
22
+ import traceback
23
+ from datetime import datetime
24
+ from dotenv import load_dotenv
25
+
26
+ # Carrega .env
27
+ load_dotenv()
28
+
29
+ APP_URL = os.getenv("APP_URL", "http://localhost:8501")
30
+ LOGIN_USER = os.getenv("LOGIN_USER", "admin")
31
+ LOGIN_PASS = os.getenv("LOGIN_PASS", "admin123")
32
+ BANK_CHOICE = os.getenv("BANK_CHOICE", "prod") # prod | test | treinamento
33
+
34
+ SCREEN_DIR = os.getenv("SCREEN_DIR", "./screenshots")
35
+ OUTPUT_PPTX = os.getenv("OUTPUT_PPTX", "./demo_funcionalidades.pptx")
36
+
37
+ HEADLESS = os.getenv("AUTOCAPTURE_HEADLESS", "false").lower() == "true"
38
+ VIEWPORT_W = int(os.getenv("AUTOCAPTURE_VIEWPORT_W", "1440"))
39
+ VIEWPORT_H = int(os.getenv("AUTOCAPTURE_VIEWPORT_H", "900"))
40
+
41
+ # Importa seu mapa de módulos (aproveita rótulos e grupos)
42
+ try:
43
+ from modules_map import MODULES
44
+ except Exception:
45
+ MODULES = {}
46
+ print("⚠️ Não consegui importar modules_map.py. Ele deve estar no mesmo diretório do script.")
47
+
48
+ # PowerPoint
49
+ from pptx import Presentation
50
+ from pptx.util import Inches, Pt
51
+ from pptx.dml.color import RGBColor
52
+
53
+ # Playwright
54
+ from playwright.sync_api import sync_playwright
55
+
56
+
57
+ # -----------------------------------------------------------------------------
58
+ # Helpers
59
+ # -----------------------------------------------------------------------------
60
+ def ensure_dir(path: str):
61
+ os.makedirs(path, exist_ok=True)
62
+
63
+ def sanitize(s: str) -> str:
64
+ """Remove/normaliza caracteres inválidos de nomes (Windows-safe)."""
65
+ s = re.sub(r"[\\/:*?\"<>|]", "_", s) # remove proibidos
66
+ s = re.sub(r"\s+", "_", s.strip()) # espaços -> _
67
+ return s
68
+
69
+ def bank_label(choice: str) -> str:
70
+ return {
71
+ "prod": "Banco 1 (📗 Produção)",
72
+ "test": "Banco 2 (📕 Teste)",
73
+ "treinamento": "Banco 3 (📘 Treinamento)",
74
+ }.get(choice, choice)
75
+
76
+ def save_artifacts_on_fail(page, tag="fail"):
77
+ """Salva HTML e screenshot quando algo dá errado."""
78
+ ensure_dir(SCREEN_DIR)
79
+ tag = sanitize(tag)
80
+ try:
81
+ html_path = os.path.join(SCREEN_DIR, f"{tag}_page.html")
82
+ img_path = os.path.join(SCREEN_DIR, f"{tag}_page.png")
83
+ with open(html_path, "w", encoding="utf-8") as f:
84
+ f.write(page.content())
85
+ page.screenshot(path=img_path, full_page=True)
86
+ print(f"📝 Artefatos salvos: {html_path}, {img_path}")
87
+ except Exception as e:
88
+ print(f"⚠️ Falha ao salvar artefatos de erro: {e}")
89
+
90
+ def select_by_label(page, select_label: str, option_text: str):
91
+ """
92
+ Seleciona uma opção em um st.selectbox, procurando pelo label (texto visível).
93
+ • Varre todos os elementos com data-testid="stSelectbox"
94
+ • Encontra o que contém o label desejado (case-insensitive)
95
+ • Abre o combobox e clica na opção exata
96
+ """
97
+ boxes = page.locator('[data-testid="stSelectbox"]')
98
+ count = boxes.count()
99
+ if count == 0:
100
+ raise RuntimeError("Nenhum stSelectbox encontrado na página.")
101
+
102
+ found = False
103
+ for i in range(count):
104
+ box = boxes.nth(i)
105
+ try:
106
+ txt = box.inner_text().strip()
107
+ except Exception:
108
+ continue
109
+ if select_label.lower() in txt.lower():
110
+ box.locator('div[role="combobox"]').first.click()
111
+ page.locator('div[role="listbox"]').get_by_text(option_text, exact=True).click()
112
+ found = True
113
+ break
114
+
115
+ if not found:
116
+ raise RuntimeError(f"Selectbox com label '{select_label}' não encontrado.")
117
+
118
+ def bypass_quiz(page):
119
+ """
120
+ Tenta sair da tela de Quiz, caso esteja bloqueando a navegação.
121
+ Procura ações típicas: 'Voltar ao sistema', 'Finalizar', 'Continuar'.
122
+ """
123
+ # 1) Voltar ao sistema
124
+ try:
125
+ if page.get_by_text("Voltar ao sistema").count() > 0:
126
+ page.get_by_text("Voltar ao sistema").click()
127
+ page.wait_for_timeout(600)
128
+ return
129
+ except Exception:
130
+ pass
131
+
132
+ # 2) Finalizar
133
+ try:
134
+ if page.get_by_role("button", name="Finalizar").count() > 0:
135
+ page.get_by_role("button", name="Finalizar").click()
136
+ page.wait_for_timeout(600)
137
+ return
138
+ except Exception:
139
+ pass
140
+
141
+ # 3) Continuar
142
+ try:
143
+ if page.get_by_role("button", name="Continuar").count() > 0:
144
+ page.get_by_role("button", name="Continuar").click()
145
+ page.wait_for_timeout(600)
146
+ return
147
+ except Exception:
148
+ pass
149
+
150
+ # 4) Se nada funcionar, salva artefatos para analisarmos o DOM real
151
+ save_artifacts_on_fail(page, "quiz_bypass")
152
+
153
+
154
+ def do_login(page):
155
+ page.goto(APP_URL, timeout=60000)
156
+ page.wait_for_load_state("networkidle")
157
+ page.wait_for_timeout(800)
158
+
159
+ # Seleciona Banco (selectbox "Usar banco:")
160
+ try:
161
+ select_by_label(page, "Usar banco:", bank_label(BANK_CHOICE))
162
+ except Exception as e:
163
+ print(f"⚠️ Falha ao selecionar banco: {e}")
164
+ save_artifacts_on_fail(page, "select_bank")
165
+ # Fallback por texto simples (última tentativa)
166
+ try:
167
+ page.get_by_text("Usar banco:").click()
168
+ page.get_by_text(bank_label(BANK_CHOICE), exact=True).click()
169
+ except Exception:
170
+ pass
171
+
172
+ # Preenche credenciais
173
+ try:
174
+ page.get_by_label("Usuário").fill(LOGIN_USER)
175
+ except Exception:
176
+ page.locator('label:has-text("Usuário")').locator("xpath=..").locator('input').fill(LOGIN_USER)
177
+
178
+ try:
179
+ page.get_by_label("Senha").fill(LOGIN_PASS)
180
+ except Exception:
181
+ page.locator('label:has-text("Senha")').locator("xpath=..").locator('input').fill(LOGIN_PASS)
182
+
183
+ # Entrar
184
+ try:
185
+ page.get_by_role("button", name="Entrar").click()
186
+ except Exception:
187
+ page.get_by_text("Entrar").click()
188
+
189
+ page.wait_for_load_state("networkidle")
190
+ page.wait_for_timeout(1000)
191
+
192
+ # Captura pós-login
193
+ page.screenshot(path=os.path.join(SCREEN_DIR, "00_pos_login.png"), full_page=True)
194
+
195
+ # Bypass do Quiz (se existir)
196
+ bypass_quiz(page)
197
+
198
+ # Captura pós-quiz
199
+ page.screenshot(path=os.path.join(SCREEN_DIR, "01_pos_quiz.png"), full_page=True)
200
+
201
+
202
+ def clear_search(page):
203
+ """Limpa campo 'Pesquisar módulo:' para não filtrar nada (opcional)."""
204
+ try:
205
+ page.get_by_label("Pesquisar módulo:").fill("")
206
+ page.wait_for_timeout(200)
207
+ except Exception:
208
+ # Fallback: tenta input na sidebar
209
+ try:
210
+ sb = page.locator('[data-testid="stSidebar"]').first
211
+ sb.locator('input').first.fill("")
212
+ except Exception:
213
+ pass
214
+
215
+
216
+ def capture_all_screens():
217
+ ensure_dir(SCREEN_DIR)
218
+ screenshots = []
219
+
220
+ from playwright.sync_api import TimeoutError
221
+
222
+ with sync_playwright() as pw:
223
+ browser = pw.chromium.launch(headless=HEADLESS)
224
+ context = browser.new_context(viewport={"width": VIEWPORT_W, "height": VIEWPORT_H})
225
+ page = context.new_page()
226
+
227
+ # Login
228
+ do_login(page)
229
+
230
+ # Grupos
231
+ grupos = sorted({MODULES[mid].get("grupo", "Outros") for mid in MODULES}) if MODULES else []
232
+ if not grupos:
233
+ print("⚠️ MODULES está vazio. Não há módulos para capturar.")
234
+ save_artifacts_on_fail(page, "no_modules")
235
+ context.close(); browser.close()
236
+ return screenshots
237
+
238
+ for grupo in grupos:
239
+ try:
240
+ clear_search(page)
241
+ select_by_label(page, "Selecione a operação:", grupo)
242
+ page.wait_for_timeout(500)
243
+
244
+ gshot = os.path.join(SCREEN_DIR, f"{BANK_CHOICE}_grupo_{sanitize(grupo)}.png")
245
+ page.screenshot(path=gshot, full_page=True)
246
+ print(f"📸 Grupo: {grupo} → {gshot}")
247
+ except Exception as e:
248
+ print(f"⚠️ Falha ao selecionar grupo '{grupo}': {e}")
249
+ save_artifacts_on_fail(page, f"grupo_{grupo}")
250
+ continue
251
+
252
+ # Módulos do grupo
253
+ mod_ids = [mid for mid in MODULES if MODULES[mid].get("grupo", "Outros") == grupo]
254
+ for mid in mod_ids:
255
+ label = MODULES[mid].get("label", mid)
256
+ try:
257
+ select_by_label(page, "Selecione o módulo:", label)
258
+ page.wait_for_load_state("networkidle")
259
+ page.wait_for_timeout(800)
260
+
261
+ fname = f"{BANK_CHOICE}_{sanitize(grupo)}_{sanitize(mid)}.png"
262
+ fpath = os.path.join(SCREEN_DIR, fname)
263
+ page.screenshot(path=fpath, full_page=True)
264
+ screenshots.append((mid, label, grupo, fpath))
265
+ print(f"📸 Módulo: {label} → {fpath}")
266
+ except Exception as e:
267
+ print(f"❌ Falha ao capturar módulo '{label}': {e}")
268
+ save_artifacts_on_fail(page, f"mod_{mid}")
269
+ traceback.print_exc()
270
+ continue
271
+
272
+ context.close()
273
+ browser.close()
274
+
275
+ return screenshots
276
+
277
+
278
+ def build_pptx(screens, out_path):
279
+ prs = Presentation()
280
+
281
+ # Slide de título
282
+ slide = prs.slides.add_slide(prs.slide_layouts[0])
283
+ slide.shapes.title.text = "Apresentação do Sistema (ARM LoadApp)"
284
+ subtitle = slide.placeholders[1].text_frame
285
+ subtitle.clear()
286
+ p = subtitle.paragraphs[0]
287
+ p.text = f"Ambiente: {bank_label(BANK_CHOICE)} | Gerado em {datetime.now().strftime('%d/%m/%Y %H:%M:%S')}"
288
+ p.font.size = Pt(14)
289
+
290
+ # Slides por módulo
291
+ for mid, label, grupo, fpath in screens:
292
+ layout = prs.slides.add_slide(prs.slide_layouts[5]) # Title Only
293
+ layout.shapes.title.text = f"{label} • {grupo}"
294
+ left, top, width = Inches(0.5), Inches(1.2), Inches(9)
295
+ try:
296
+ layout.shapes.add_picture(fpath, left, top, width=width)
297
+ except Exception:
298
+ tx = layout.shapes.add_textbox(left, top, Inches(9), Inches(1))
299
+ tf = tx.text_frame
300
+ tf.text = f"(Falha ao inserir imagem: {os.path.basename(fpath)})"
301
+ tf.paragraphs[0].font.color.rgb = RGBColor(200, 0, 0)
302
+
303
+ prs.save(out_path)
304
+ print(f"🎉 PPTX gerado: {out_path}")
305
+
306
+
307
+ def main():
308
+ print(f"🚀 Captura em {APP_URL} | Banco: {BANK_CHOICE} ({bank_label(BANK_CHOICE)}) | headless={HEADLESS}")
309
+ ensure_dir(SCREEN_DIR)
310
+ screens = capture_all_screens()
311
+ if not screens:
312
+ print("⚠️ Nenhuma captura gerada. Veja os artefatos na pasta e revise seletores/menus.")
313
+ return
314
+ build_pptx(screens, OUTPUT_PPTX)
315
+
316
+
317
+ if __name__ == "__main__":
318
+ main()
319
+
banco.py ADDED
@@ -0,0 +1,121 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ from sqlalchemy import create_engine
3
+ from sqlalchemy.orm import sessionmaker, declarative_base
4
+ import os
5
+ from dotenv import load_dotenv
6
+ import importlib
7
+
8
+ # 🔒 Caminho absoluto do projeto
9
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
10
+
11
+ # Carrega variáveis de ambiente (.env) antes de ler DATABASE_URL
12
+ load_dotenv()
13
+
14
+ # ============================================================
15
+ # 🔀 SUPORTE A DOIS BANCOS (Produção/Teste) COM FALLBACK
16
+ # ============================================================
17
+ # Tentamos usar o roteador (db_router.py). Se não existir ainda,
18
+ # caímos no comportamento original usando apenas DATABASE_URL.
19
+ try:
20
+ from db_router import (
21
+ get_engine as _router_get_engine,
22
+ get_session_factory as _router_get_session_factory,
23
+ SessionLocal as _router_SessionLocal,
24
+ )
25
+ _HAS_ROUTER = True
26
+ except Exception:
27
+ _HAS_ROUTER = False
28
+
29
+ # 🔧 Fallback: mesma lógica do seu módulo original — um único DATABASE_URL
30
+ DATABASE_URL = os.getenv(
31
+ "DATABASE_URL",
32
+ f"sqlite:///{os.path.join(BASE_DIR, 'load.db')}"
33
+ )
34
+
35
+ engine_args = {
36
+ "echo": False,
37
+ "pool_pre_ping": True,
38
+ }
39
+
40
+ # Parâmetros específicos para SQLite (apenas se o fallback estiver ativo)
41
+ if DATABASE_URL.startswith("sqlite"):
42
+ engine_args["connect_args"] = {"check_same_thread": False}
43
+
44
+ # ============================================================
45
+ # Engine / SessionLocal (com ou sem roteador)
46
+ # ============================================================
47
+ if _HAS_ROUTER:
48
+ # ✅ Usa engine e SessionLocal do banco ATIVO (Produção/Teste), conforme escolha no login
49
+ def get_engine():
50
+ return _router_get_engine()
51
+
52
+ def _session_factory():
53
+ return _router_get_session_factory()
54
+
55
+ # A SessionLocal do roteador já entrega sessões no banco ativo
56
+ SessionLocal = _router_SessionLocal
57
+
58
+ else:
59
+ # ✅ Fallback: comportamento original com DATABASE_URL único
60
+ _engine = create_engine(DATABASE_URL, **engine_args)
61
+
62
+ def get_engine():
63
+ return _engine
64
+
65
+ _SessionFactory = sessionmaker(
66
+ autocommit=False,
67
+ autoflush=False,
68
+ bind=_engine,
69
+ )
70
+
71
+ def _session_factory():
72
+ return _SessionFactory
73
+
74
+ # Compatível com seu uso atual: SessionLocal() -> sessão
75
+ SessionLocal = _SessionFactory
76
+
77
+ # ⚠️ Compatibilidade: expõe 'engine' resolvendo via get_engine()
78
+ # Observação importante:
79
+ # - Se trocar o banco após a importação deste módulo (via login),
80
+ # prefira sempre chamar get_engine() ou criar sessões com SessionLocal(),
81
+ # pois 'engine' abaixo é resolvido apenas uma vez (na importação).
82
+ engine = get_engine()
83
+
84
+ # ORM Base
85
+ Base = declarative_base()
86
+
87
+ # ============================================================
88
+ # 🛠️ Utilitários (opcionais)
89
+ # ============================================================
90
+ def init_schema():
91
+ """
92
+ Cria/atualiza as tabelas no banco ATIVO.
93
+ • Com roteador: aplica no banco escolhido (Produção/Teste).
94
+ • Sem roteador: aplica no DATABASE_URL padrão.
95
+ Use em DEV/TESTE; em produção, prefira migrações (ex.: Alembic).
96
+ """
97
+ # Importa 'models' de forma tardia e segura (sem wildcard) para registrar todos os mapeamentos
98
+ # antes de criar as tabelas. Isso evita import circular no topo.
99
+ try:
100
+ importlib.import_module("models")
101
+ except ModuleNotFoundError:
102
+ # Se seus modelos estiverem em outro pacote/caminho, ajuste aqui:
103
+ # importlib.import_module("app.models") # exemplo
104
+ raise
105
+
106
+ Base.metadata.create_all(bind=get_engine())
107
+
108
+ def db_info() -> dict:
109
+ """
110
+ Retorna informações básicas do banco ativo (para debug/UX).
111
+ """
112
+ eng = get_engine()
113
+ try:
114
+ url = str(eng.url)
115
+ except Exception:
116
+ url = DATABASE_URL
117
+ return {
118
+ "url": url,
119
+ "using_router": _HAS_ROUTER,
120
+ }
121
+
bi.py ADDED
@@ -0,0 +1,74 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import streamlit.components.v1 as components
3
+ from banco import SessionLocal
4
+ from models import LogAcesso
5
+ from datetime import datetime
6
+
7
+
8
+ # =====================================================
9
+ # AUDITORIA
10
+ # =====================================================
11
+ def registrar_auditoria(acao):
12
+ db = SessionLocal()
13
+ try:
14
+ db.add(LogAcesso(
15
+ usuario=st.session_state.get("usuario", "desconhecido"),
16
+ acao=acao,
17
+ tabela="bi",
18
+ data_hora=datetime.now()
19
+ ))
20
+ db.commit()
21
+ finally:
22
+ db.close()
23
+
24
+
25
+ # =====================================================
26
+ # APP PRINCIPAL
27
+ # =====================================================
28
+ def main():
29
+ st.title("📊 Business Intelligence")
30
+
31
+ st.caption("Indicadores e dashboards oficiais")
32
+
33
+ # 🔐 Auditoria de acesso
34
+ registrar_auditoria("ACESSO_BI")
35
+
36
+ # =====================================================
37
+ # SELEÇÃO DE DASHBOARD
38
+ # =====================================================
39
+ dashboards = {
40
+ "📈 Performance Operacional": {
41
+ "url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI",
42
+ "height": 800
43
+ },
44
+ "📊 Qualidade e Erros": {
45
+ "url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI",
46
+ "height": 800
47
+ },
48
+ "📦 Produtividade FPSO": {
49
+ "url": "https://app.powerbi.com/view?r=SEU_LINK_AQUI",
50
+ "height": 900
51
+ }
52
+ }
53
+
54
+ opcao = st.selectbox("Selecione o Dashboard", dashboards.keys())
55
+
56
+ dash = dashboards[opcao]
57
+
58
+ st.divider()
59
+
60
+ # =====================================================
61
+ # EMBED DO POWER BI
62
+ # =====================================================
63
+ components.html(
64
+ f"""
65
+ <iframe
66
+ width="100%"
67
+ height="{dash['height']}"
68
+ src="{dash['url']}"
69
+ frameborder="0"
70
+ allowfullscreen="true">
71
+ </iframe>
72
+ """,
73
+ height=dash["height"] + 20
74
+ )
cadastro_py.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from banco import SessionLocal
2
+ from models import FPSO
3
+
4
+ FPSO_PADRAO = [
5
+ "CDA", "CDP", "CDM", "ADG",
6
+ "ESS", "SEP", "CDI", "ATD", "CDS"
7
+ ]
8
+
9
+ def main():
10
+ db = SessionLocal()
11
+ try:
12
+ existentes = {f.nome for f in db.query(FPSO).all()}
13
+
14
+ for nome in FPSO_PADRAO:
15
+ if nome not in existentes:
16
+ db.add(FPSO(nome=nome))
17
+
18
+ db.commit()
19
+ print("✅ FPSOs padrão inseridos com sucesso!")
20
+
21
+ finally:
22
+ db.close()
23
+
24
+
25
+ if __name__ == "__main__":
26
+ main()
27
+
28
+
calendario.py ADDED
@@ -0,0 +1,708 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ from datetime import date, datetime, timedelta
5
+ from typing import Dict, List
6
+ from banco import SessionLocal
7
+ from models import EventoCalendario
8
+ from utils_permissoes import verificar_permissao
9
+ from log import registrar_log
10
+ from utils_datas import formatar_data_br
11
+
12
+ # ⬇️ Componente de calendário
13
+ from streamlit_calendar import calendar
14
+
15
+ # =====================================================
16
+ # 📅 CALENDÁRIO + CRONOGRAMA ANUAL (D-3 / D-2 / D-1 / D 🚢)
17
+ # =====================================================
18
+
19
+ # ------------------------------
20
+ # ⚙️ Regras de embarque (fase/seed e passo)
21
+ # ------------------------------
22
+ # seed_day = dia (de Janeiro) usado como "D" inicial para o ano selecionado
23
+ # step = dias entre embarques (D → próximo D)
24
+ REGRAS_FPSO = {
25
+ "ATD": {"seed_day": 1, "step": 5},
26
+ "ADG": {"seed_day": 1, "step": 5},
27
+ "CDM": {"seed_day": 2, "step": 5},
28
+ "CDP": {"seed_day": 2, "step": 5},
29
+ "CDS": {"seed_day": 2, "step": 5},
30
+ "CDI": {"seed_day": 5, "step": 5}, # (ACDI → CDI)
31
+ "CDA": {"seed_day": 5, "step": 5},
32
+ "SEP": {"seed_day": 4, "step": 4}, # sem dia vazio
33
+ "ESS": {"seed_day": 3, "step": 7}, # blocos com pausa maior
34
+ }
35
+
36
+ # 🎨 Paleta
37
+ COLOR_MAP = {
38
+ "D-3": "#00B050", # verde
39
+ "D-2": "#FF0000", # vermelho
40
+ "D-1": "#C00000", # vermelho escuro
41
+ "D": "#7F7F7F", # cinza
42
+ }
43
+
44
+ EMOJI_NAVIO = " 🚢" # adicionado aos títulos no dia D
45
+
46
+
47
+ def _usuario_atual() -> str:
48
+ return (st.session_state.get("usuario") or "sistema")
49
+
50
+
51
+ def _criar_evento_fc(title: str, dt: date, color: str, extra: Dict = None) -> dict:
52
+ """Monta um evento no formato FullCalendar/streamlit_calendar."""
53
+ ev = {
54
+ "id": f"auto::{title}::{dt.isoformat()}",
55
+ "title": title,
56
+ "start": dt.isoformat(),
57
+ "allDay": True,
58
+ "color": color,
59
+ "extendedProps": {"gerado_auto": True},
60
+ }
61
+ if extra:
62
+ ev["extendedProps"].update(extra)
63
+ return ev
64
+
65
+
66
+ def _rotulo_antes_de_d(dias: int) -> str:
67
+ """Converte o deslocamento até D para rótulo: 0->D, 1->D-1, 2->D-2, 3->D-3, outros->''"""
68
+ if dias == 0:
69
+ return "D"
70
+ if dias in (1, 2, 3):
71
+ return f"D-{dias}"
72
+ return ""
73
+
74
+
75
+ def _gerar_cronograma_ano(
76
+ ano: int,
77
+ fpsos_sel: List[str],
78
+ incluir_anteriores: bool = True,
79
+ apenas_D: bool = False,
80
+ ) -> List[dict]:
81
+ """
82
+ Gera eventos 'D-3/D-2/D-1/D 🚢' para TODO o ano.
83
+ - incluir_anteriores: inclui D-1..D-3 que caem no começo do ano (vindo do D-semente).
84
+ - apenas_D: se True, somente 'D 🚢'.
85
+ """
86
+ events = []
87
+ dt_ini = date(ano, 1, 1)
88
+ dt_fim = date(ano, 12, 31)
89
+
90
+ for fpso in fpsos_sel:
91
+ cfg = REGRAS_FPSO.get(fpso)
92
+ if not cfg:
93
+ continue
94
+ seed_day = max(1, min(cfg["seed_day"], 28)) # segurança (fev)
95
+ seed = date(ano, 1, seed_day)
96
+ step = int(cfg["step"])
97
+
98
+ # Todos os D do ano
99
+ d = seed
100
+ while d <= dt_fim:
101
+ if d >= dt_ini:
102
+ # D (com emoji) + cor
103
+ titulo_d = f"{fpso} – D{EMOJI_NAVIO}"
104
+ events.append(
105
+ _criar_evento_fc(
106
+ titulo_d, d, COLOR_MAP["D"],
107
+ {"tipo": "D", "fpso": fpso}
108
+ )
109
+ )
110
+ if not apenas_D:
111
+ # D-1..D-3
112
+ for k in (1, 2, 3):
113
+ dk = d - timedelta(days=k)
114
+ if dt_ini <= dk <= dt_fim:
115
+ label = f"D-{k}"
116
+ events.append(
117
+ _criar_evento_fc(
118
+ f"{fpso} – {label}",
119
+ dk,
120
+ COLOR_MAP[label],
121
+ {"tipo": label, "fpso": fpso},
122
+ )
123
+ )
124
+ d += timedelta(days=step)
125
+
126
+ # Cobertura no início do ano (apenas rótulos anteriores ao D-semente)
127
+ if incluir_anteriores and not apenas_D:
128
+ for k in (1, 2, 3):
129
+ dk = seed - timedelta(days=k)
130
+ if dt_ini <= dk <= dt_fim:
131
+ label = f"D-{k}"
132
+ events.append(
133
+ _criar_evento_fc(
134
+ f"{fpso} – {label}",
135
+ dk,
136
+ COLOR_MAP[label],
137
+ {"tipo": label, "fpso": fpso},
138
+ )
139
+ )
140
+ return events
141
+
142
+
143
+ def _gerar_cronograma_intervalo(
144
+ ano_ini: int,
145
+ ano_fim: int,
146
+ fpsos_sel: List[str],
147
+ apenas_D: bool = False,
148
+ ) -> List[dict]:
149
+ """Gera eventos para [ano_ini..ano_fim]."""
150
+ out = []
151
+ for y in range(ano_ini, ano_fim + 1):
152
+ out.extend(_gerar_cronograma_ano(y, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D))
153
+ return out
154
+
155
+
156
+ def _titulo_normalizado(titulo: str) -> str:
157
+ """Remove o emoji ' 🚢' apenas para comparação/deduplicação."""
158
+ return titulo.replace(EMOJI_NAVIO, "")
159
+
160
+
161
+ def _dedup_chave(titulo: str, data_evt: date) -> str:
162
+ """Chave de de-duplicação (título normalizado + data)."""
163
+ return f"{_titulo_normalizado(titulo)}::{data_evt.isoformat()}"
164
+
165
+
166
+ def _gravar_cronograma_no_banco(db, eventos_fc: List[dict]) -> int:
167
+ """
168
+ Grava no banco eventos 'gerado_auto' evitando duplicados (ignorando emoji).
169
+ Retorna contagem de inserções.
170
+ """
171
+ # Pré-carregar existentes no intervalo abrangido
172
+ if not eventos_fc:
173
+ return 0
174
+ min_day = min(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc)
175
+ max_day = max(date.fromisoformat(ev["start"][:10]) for ev in eventos_fc)
176
+
177
+ existentes = (
178
+ db.query(EventoCalendario)
179
+ .filter(EventoCalendario.data_evento >= min_day)
180
+ .filter(EventoCalendario.data_evento <= max_day)
181
+ .filter(EventoCalendario.ativo.is_(True))
182
+ .all()
183
+ )
184
+ idx_existentes = {
185
+ _dedup_chave(e.titulo, e.data_evento): e.id for e in existentes
186
+ }
187
+
188
+ ins = 0
189
+ for ev in eventos_fc:
190
+ if not ev.get("extendedProps", {}).get("gerado_auto"):
191
+ continue
192
+ titulo = ev["title"]
193
+ dt = date.fromisoformat(ev["start"][:10])
194
+ k = _dedup_chave(titulo, dt)
195
+ if k in idx_existentes:
196
+ continue
197
+ novo = EventoCalendario(
198
+ titulo=titulo, # mantém o emoji nos D
199
+ descricao=f"Cronograma automático ({ev['extendedProps'].get('tipo','')})",
200
+ data_evento=dt,
201
+ data_lembrete=None,
202
+ ativo=True,
203
+ usuario_criacao=_usuario_atual(),
204
+ data_criacao=datetime.now(),
205
+ )
206
+ db.add(novo)
207
+ try:
208
+ db.commit()
209
+ ins += 1
210
+ except Exception:
211
+ db.rollback()
212
+ return ins
213
+
214
+
215
+ def _remover_cronograma_do_banco_intervalo(db, fpsos_sel: List[str], ano_ini: int, ano_fim: int) -> int:
216
+ """
217
+ Remove do banco os eventos gerados por este módulo, para [ano_ini..ano_fim] e FPSOs.
218
+ Busca por títulos ('<FPSO> – D' / ' – D 🚢' / ' – D-1/2/3') e data no intervalo.
219
+ """
220
+ ini = date(ano_ini, 1, 1)
221
+ fim = date(ano_fim, 12, 31)
222
+ total = 0
223
+ for fpso in fpsos_sel:
224
+ base = [f"{fpso} – D", f"{fpso} – D-1", f"{fpso} – D-2", f"{fpso} – D-3"]
225
+ # inclui com emoji para D
226
+ variantes = base + [f"{fpso} – D{EMOJI_NAVIO}"]
227
+ to_del = (
228
+ db.query(EventoCalendario)
229
+ .filter(EventoCalendario.data_evento >= ini)
230
+ .filter(EventoCalendario.data_evento <= fim)
231
+ .filter(EventoCalendario.titulo.in_(variantes))
232
+ .all()
233
+ )
234
+ for e in to_del:
235
+ db.delete(e)
236
+ total += 1
237
+ try:
238
+ db.commit()
239
+ except Exception:
240
+ db.rollback()
241
+ return total
242
+
243
+
244
+ def main():
245
+
246
+ # =====================================================
247
+ # 🔒 PROTEÇÃO POR PERFIL
248
+ # =====================================================
249
+ if not verificar_permissao("calendario"):
250
+ st.error("⛔ Acesso não autorizado.")
251
+ return
252
+
253
+ st.title("📅 Calendário e Lembretes")
254
+
255
+ hoje = date.today()
256
+ db = SessionLocal()
257
+
258
+ # Helper: cor por status (eventos do banco)
259
+ def _cor_evento_db(e: "EventoCalendario") -> str:
260
+ if not e.ativo:
261
+ return "#95a5a6" # Cinza
262
+ if e.data_evento < hoje:
263
+ return "#e74c3c" # Vermelho (passado)
264
+ if e.data_lembrete and e.data_lembrete == hoje:
265
+ return "#f39c12" # Laranja (lembrete hoje)
266
+ return "#2ecc71" # Verde (ativo futuro)
267
+
268
+ # Converte EventoCalendario do banco → FullCalendar
269
+ def _to_fc_event_db(e: "EventoCalendario") -> dict:
270
+ return {
271
+ "id": str(e.id),
272
+ "title": e.titulo,
273
+ "start": e.data_evento.isoformat(),
274
+ "allDay": True,
275
+ "color": _cor_evento_db(e),
276
+ "extendedProps": {
277
+ "descricao": (e.descricao or ""),
278
+ "data_evento": e.data_evento.isoformat(),
279
+ "data_lembrete": e.data_lembrete.isoformat() if e.data_lembrete else None,
280
+ "ativo": e.ativo,
281
+ "gerado_auto": False,
282
+ },
283
+ }
284
+
285
+ try:
286
+ # =====================================================
287
+ # 🔔 LEMBRETES DO DIA
288
+ # =====================================================
289
+ st.subheader("⏰ Lembretes de Hoje | Adicione o calendário da sua embarcação")
290
+
291
+ lembretes = (
292
+ db.query(EventoCalendario)
293
+ .filter(EventoCalendario.data_lembrete == hoje)
294
+ .filter(EventoCalendario.ativo.is_(True))
295
+ .order_by(EventoCalendario.data_evento)
296
+ .all()
297
+ )
298
+
299
+ if lembretes:
300
+ for l in lembretes:
301
+ st.warning(f"🔔 **{l.titulo}** — Evento em {formatar_data_br(l.data_evento)}")
302
+ else:
303
+ st.info("Nenhum lembrete para hoje.")
304
+
305
+ st.divider()
306
+
307
+ # =====================================================
308
+ # 🎛️ CONTROLES DO CRONOGRAMA
309
+ # =====================================================
310
+ st.subheader("🛠️ Cronograma de Embarques (D-3 / D-2 / D-1 / D 🚢)")
311
+
312
+ col_a, col_b, col_c = st.columns([1, 2, 2])
313
+ with col_a:
314
+ ano_sel = st.number_input(
315
+ "Ano",
316
+ min_value=2000, max_value=2100,
317
+ value=hoje.year, step=1, key="cal_ano_sel"
318
+ )
319
+
320
+ fpsos_all = list(REGRAS_FPSO.keys())
321
+ with col_b:
322
+ fpsos_sel = st.multiselect(
323
+ "FPSOs",
324
+ options=fpsos_all,
325
+ default=fpsos_all,
326
+ key="cal_fpsos_sel",
327
+ )
328
+ if not fpsos_sel:
329
+ fpsos_sel = fpsos_all
330
+
331
+ with col_c:
332
+ apenas_D = st.checkbox("Exibir apenas dias de Embarque (D)", value=False)
333
+
334
+ # Gera cronograma em memória para o ANO selecionado (visualização)
335
+ eventos_auto = _gerar_cronograma_ano(
336
+ ano_sel, fpsos_sel, incluir_anteriores=True, apenas_D=apenas_D
337
+ )
338
+
339
+ # 🔁 Ações de banco: ANO
340
+ col_b1, col_b2, col_b3, col_b4 = st.columns([1.7, 1.7, 2, 2])
341
+ with col_b1:
342
+ if st.button("💾 Gravar cronograma (ano) no banco"):
343
+ qtd = _gravar_cronograma_no_banco(db, eventos_auto)
344
+ if qtd > 0:
345
+ registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", None)
346
+ st.success(f"Cronograma do ano {ano_sel} gravado/atualizado. Inserções: {qtd}.")
347
+ st.rerun()
348
+ with col_b2:
349
+ if st.button("🧹 Remover cronograma (ano) do banco"):
350
+ qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, ano_sel)
351
+ if qtd > 0:
352
+ registrar_log(_usuario_atual(), "EXCLUIR", "eventos_calendario", None)
353
+ st.warning(f"Eventos removidos do banco (ano {ano_sel}): {qtd}.")
354
+ st.rerun()
355
+
356
+ # 🔁 Ações de banco: INTERVALO ATÉ 2030
357
+ with col_b3:
358
+ if st.button("💾 Gravar cronograma até 2030 (banco)"):
359
+ eventos_lote = _gerar_cronograma_intervalo(
360
+ ano_ini=ano_sel, ano_fim=2030, fpsos_sel=fpsos_sel, apenas_D=apenas_D
361
+ )
362
+ qtd = _gravar_cronograma_no_banco(db, eventos_lote)
363
+ if qtd > 0:
364
+ registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", None)
365
+ st.success(f"Cronogramas {ano_sel}–2030 gravados/atualizados. Inserções: {qtd}.")
366
+ st.rerun()
367
+ with col_b4:
368
+ if st.button("🧹 Remover cronograma até 2030 (banco)"):
369
+ qtd = _remover_cronograma_do_banco_intervalo(db, fpsos_sel, ano_sel, 2030)
370
+ if qtd > 0:
371
+ registrar_log(_usuario_atual(), "EXCLUIR", "eventos_calendario", None)
372
+ st.warning(f"Eventos removidos do banco ({ano_sel}–2030): {qtd}.")
373
+ st.rerun()
374
+
375
+ st.caption(
376
+ "• A geração automática **não** altera seus eventos manuais. "
377
+ "Use os botões para **gravar** ou **remover** do banco apenas os eventos criados por este módulo. "
378
+ "Nos dias de **D**, o título inclui o ícone de navio (🚢)."
379
+ )
380
+
381
+ st.divider()
382
+
383
+ # =====================================================
384
+ # ➕ NOVO EVENTO / LEMBRETE (manual)
385
+ # =====================================================
386
+ with st.expander("➕ Novo Evento / Lembrete"):
387
+ with st.form("form_evento"):
388
+ titulo = st.text_input("Título *")
389
+ descricao = st.text_area("Descrição")
390
+ data_evento = st.date_input("Data do Evento", value=hoje, format="DD/MM/YYYY")
391
+ data_lembrete = st.date_input("Data do Lembrete (opcional)", value=None, format="DD/MM/YYYY")
392
+ ativo = st.checkbox("Evento ativo", value=True)
393
+ salvar = st.form_submit_button("💾 Salvar Evento")
394
+
395
+ if salvar:
396
+ if not titulo.strip():
397
+ st.error("⚠️ O título é obrigatório.")
398
+ elif data_lembrete and (data_lembrete > data_evento):
399
+ st.error("⚠️ O lembrete não pode ser após a data do evento.")
400
+ else:
401
+ evento = EventoCalendario(
402
+ titulo=titulo.strip(),
403
+ descricao=(descricao or "").strip(),
404
+ data_evento=data_evento,
405
+ data_lembrete=data_lembrete,
406
+ ativo=ativo,
407
+ usuario_criacao=_usuario_atual(),
408
+ data_criacao=datetime.now()
409
+ )
410
+ db.add(evento)
411
+ try:
412
+ db.commit()
413
+ except Exception as e:
414
+ db.rollback()
415
+ st.error(f"❌ Erro ao salvar evento: {e}")
416
+ else:
417
+ registrar_log(_usuario_atual(), "CRIAR", "eventos_calendario", evento.id)
418
+ st.success("✅ Evento criado com sucesso!")
419
+ st.rerun()
420
+
421
+ st.divider()
422
+
423
+ # =====================================================
424
+ # 📆 CALENDÁRIO (eventos do banco + cronograma do ANO selecionado)
425
+ # =====================================================
426
+ st.subheader("📆 Calendário (clique no dia ou no evento para ver a observação)")
427
+
428
+ # Banco (apenas ano selecionado na visualização)
429
+ ini_year = date(ano_sel, 1, 1)
430
+ end_year = date(ano_sel, 12, 31)
431
+ eventos_db = (
432
+ db.query(EventoCalendario)
433
+ .filter(EventoCalendario.data_evento >= ini_year)
434
+ .filter(EventoCalendario.data_evento <= end_year)
435
+ .order_by(EventoCalendario.data_evento.asc())
436
+ .all()
437
+ )
438
+ eventos_fc_db = [_to_fc_event_db(e) for e in eventos_db]
439
+
440
+ # Junta cronograma automático (memória) + banco (para a visualização do ano)
441
+ eventos_fc = eventos_fc_db + eventos_auto
442
+
443
+ options = {
444
+ "initialView": "dayGridMonth",
445
+ "locale": "pt-br",
446
+ "height": 700,
447
+ "firstDay": 1,
448
+ "weekNumbers": False,
449
+ "headerToolbar": {
450
+ "left": "prev,next today",
451
+ "center": "title",
452
+ "right": "dayGridMonth,dayGridWeek,listWeek"
453
+ },
454
+ "buttonText": {
455
+ "today": "Hoje",
456
+ "month": "Mês",
457
+ "week": "Semana",
458
+ "day": "Dia",
459
+ "list": "Lista"
460
+ },
461
+ "dayMaxEventRows": True,
462
+ "navLinks": True,
463
+ }
464
+
465
+ state = calendar(
466
+ events=eventos_fc,
467
+ options=options,
468
+ custom_css="",
469
+ key=f"calendario_eventos_{ano_sel}"
470
+ )
471
+
472
+ # Legenda
473
+ with st.container():
474
+ cols = st.columns([1.2, 1.2, 1.2, 1.2, 2.2, 3])
475
+ cols[0].markdown("⬛ **D** (cinza) " + EMOJI_NAVIO)
476
+ cols[1].markdown("🟥 **D‑1** (vinho)")
477
+ cols[2].markdown("🟥 **D‑2** (vermelho)")
478
+ cols[3].markdown("🟩 **D‑3** (verde)")
479
+ cols[4].markdown("🟧 **Lembrete hoje (eventos do banco)**")
480
+ cols[5].markdown("🟦 **Outros eventos (banco)**")
481
+
482
+ st.divider()
483
+
484
+ # =====================================================
485
+ # 🔎 Detalhe por clique (evento ou dia)
486
+ # =====================================================
487
+ clicked_event = None
488
+ if state and isinstance(state, dict):
489
+ clicked_event = (state.get("eventClick") or {}).get("event")
490
+ clicked_date_str = (state.get("dateClick") or {}).get("dateStr")
491
+ else:
492
+ clicked_date_str = None
493
+
494
+ if clicked_event:
495
+ ev_id = clicked_event.get("id")
496
+ ev_title = clicked_event.get("title")
497
+ ev_start = clicked_event.get("start")
498
+ ev_ext = clicked_event.get("extendedProps") or {}
499
+
500
+ # Se for do banco, traz detalhes atualizados
501
+ e = None
502
+ if ev_id and not str(ev_id).startswith("auto::"):
503
+ try:
504
+ e = db.query(EventoCalendario).get(int(ev_id))
505
+ except Exception:
506
+ e = None
507
+
508
+ st.subheader(f"📌 {ev_title or 'Evento'}")
509
+ if e:
510
+ st.markdown(
511
+ f"""
512
+ **Descrição:**
513
+ {e.descricao or "_Sem descrição_"}
514
+
515
+ **📅 Data do Evento:** {formatar_data_br(e.data_evento)}
516
+ **⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete) if e.data_lembrete else "_Sem lembrete_"}
517
+ **📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
518
+ """
519
+ )
520
+ if verificar_permissao("administracao"):
521
+ col1, col2 = st.columns(2)
522
+ with col1:
523
+ if e.ativo and st.button("🚫 Desativar", key=f"desativar_{e.id}"):
524
+ e.ativo = False
525
+ try:
526
+ db.commit()
527
+ except Exception as ex:
528
+ db.rollback()
529
+ st.error(f"Erro ao desativar: {ex}")
530
+ else:
531
+ registrar_log(_usuario_atual(), "DESATIVAR",
532
+ "eventos_calendario", e.id)
533
+ st.success("Evento desativado.")
534
+ st.rerun()
535
+ with col2:
536
+ if st.button("🗑️ Excluir", key=f"excluir_{e.id}"):
537
+ db.delete(e)
538
+ try:
539
+ db.commit()
540
+ except Exception as ex:
541
+ db.rollback()
542
+ st.error(f"Erro ao excluir: {ex}")
543
+ else:
544
+ registrar_log(_usuario_atual(), "EXCLUIR",
545
+ "eventos_calendario", e.id)
546
+ st.success("Evento excluído.")
547
+ st.rerun()
548
+ else:
549
+ # Evento do cronograma automático (memória)
550
+ dt_evt = date.fromisoformat(ev_start[:10])
551
+ st.markdown(
552
+ f"""
553
+ **FPSO:** {ev_title.split(' – ')[0] if ' – ' in (ev_title or '') else '—'}
554
+ **Tipo:** {ev_ext.get('tipo', '—')}
555
+ **📅 Data:** {formatar_data_br(dt_evt)}
556
+ **Origem:** _Cronograma automático (não gravado no banco)_
557
+ """
558
+ )
559
+
560
+ elif clicked_date_str:
561
+ try:
562
+ data_clicada = date.fromisoformat(clicked_date_str)
563
+ except Exception:
564
+ data_clicada = None
565
+
566
+ if data_clicada:
567
+ st.subheader(f"🗓️ Eventos em {formatar_data_br(data_clicada)}")
568
+
569
+ # Banco
570
+ eventos_no_dia_db = (
571
+ db.query(EventoCalendario)
572
+ .filter(EventoCalendario.data_evento == data_clicada)
573
+ .order_by(EventoCalendario.id.desc())
574
+ .all()
575
+ )
576
+ if not eventos_no_dia_db:
577
+ st.info("Nenhum evento do banco para este dia.")
578
+ else:
579
+ st.markdown("**📦 Eventos do banco**")
580
+ for e in eventos_no_dia_db:
581
+ with st.expander(f"📌 {e.titulo}"):
582
+ st.markdown(
583
+ f"""
584
+ **Descrição:**
585
+ {e.descricao or "_Sem descrição_"}
586
+
587
+ **📅 Data do Evento:** {formatar_data_br(e.data_evento)}
588
+ **⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete)}
589
+ **📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
590
+ """
591
+ )
592
+ if verificar_permissao("administracao"):
593
+ c1, c2 = st.columns(2)
594
+ with c1:
595
+ if e.ativo and st.button("🚫 Desativar", key=f"desativar_list_{e.id}"):
596
+ e.ativo = False
597
+ try:
598
+ db.commit()
599
+ except Exception as ex:
600
+ db.rollback()
601
+ st.error(f"Erro ao desativar: {ex}")
602
+ else:
603
+ registrar_log(_usuario_atual(), "DESATIVAR",
604
+ "eventos_calendario", e.id)
605
+ st.success("Evento desativado.")
606
+ st.rerun()
607
+ with c2:
608
+ if st.button("🗑️ Excluir", key=f"excluir_list_{e.id}"):
609
+ db.delete(e)
610
+ try:
611
+ db.commit()
612
+ except Exception as ex:
613
+ db.rollback()
614
+ st.error(f"Erro ao excluir: {ex}")
615
+ else:
616
+ registrar_log(_usuario_atual(), "EXCLUIR",
617
+ "eventos_calendario", e.id)
618
+ st.success("Evento excluído.")
619
+ st.rerun()
620
+
621
+ # Cronograma automático (memória) – ano selecionado
622
+ eventos_auto_no_dia = [
623
+ ev for ev in eventos_auto
624
+ if ev.get("start", "")[:10] == data_clicada.isoformat()
625
+ ]
626
+ if eventos_auto_no_dia:
627
+ st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**")
628
+ for ev in sorted(eventos_auto_no_dia, key=lambda x: x.get("title","")):
629
+ fps = ev.get("title","").split(" – ")[0] if " – " in ev.get("title","") else "—"
630
+ tipo = ev.get("extendedProps", {}).get("tipo", "—")
631
+ st.write(f"• **{fps}** — **{tipo}** ({formatar_data_br(data_clicada)})")
632
+
633
+ st.divider()
634
+
635
+ # =====================================================
636
+ # 📆 Consultar Eventos por Data (modo antigo) — inclui AUTO
637
+ # =====================================================
638
+ with st.expander("📆 Consultar Eventos por Data (modo antigo)"):
639
+ data_consulta = st.date_input("Selecione uma data",
640
+ value=hoje, format="DD/MM/YYYY",
641
+ key="consulta_antiga")
642
+
643
+ # Banco
644
+ eventos = (
645
+ db.query(EventoCalendario)
646
+ .filter(EventoCalendario.data_evento == data_consulta)
647
+ .order_by(EventoCalendario.id.desc())
648
+ .all()
649
+ )
650
+ if not eventos:
651
+ st.info("Nenhum evento do banco para esta data.")
652
+ else:
653
+ st.markdown("**📦 Eventos do banco**")
654
+ for e in eventos:
655
+ with st.expander(f"📌 {e.titulo}"):
656
+ st.markdown(
657
+ f"""
658
+ **Descrição:**
659
+ {e.descricao or "_Sem descrição_"}
660
+
661
+ **📅 Data do Evento:** {formatar_data_br(e.data_evento)}
662
+ **⏰ Data do Lembrete:** {formatar_data_br(e.data_lembrete)}
663
+ **📌 Status:** {"Ativo ✅" if e.ativo else "Inativo ❌"}
664
+ """
665
+ )
666
+ if verificar_permissao("administracao"):
667
+ col1, col2 = st.columns(2)
668
+ with col1:
669
+ if e.ativo and st.button("🚫 Desativar", key=f"desativar_old_{e.id}"):
670
+ e.ativo = False
671
+ try:
672
+ db.commit()
673
+ except Exception as ex:
674
+ db.rollback()
675
+ st.error(f"Erro ao desativar: {ex}")
676
+ else:
677
+ registrar_log(_usuario_atual(), "DESATIVAR",
678
+ "eventos_calendario", e.id)
679
+ st.success("Evento desativado.")
680
+ st.rerun()
681
+ with col2:
682
+ if st.button("🗑️ Excluir", key=f"excluir_old_{e.id}"):
683
+ db.delete(e)
684
+ try:
685
+ db.commit()
686
+ except Exception as ex:
687
+ db.rollback()
688
+ st.error(f"Erro ao excluir: {ex}")
689
+ else:
690
+ registrar_log(_usuario_atual(), "EXCLUIR",
691
+ "eventos_calendario", e.id)
692
+ st.success("Evento excluído.")
693
+ st.rerun()
694
+
695
+ # AUTO (memória) no ano selecionado
696
+ eventos_auto_antigo = [
697
+ ev for ev in eventos_auto
698
+ if ev.get("start", "")[:10] == data_consulta.isoformat()
699
+ ]
700
+ if eventos_auto_antigo:
701
+ st.markdown("**🛠️ Eventos gerados automaticamente (cronograma)**")
702
+ for ev in sorted(eventos_auto_antigo, key=lambda x: x.get("title","")):
703
+ fps = ev.get("title","").split(" – ")[0] if " – " in ev.get("title","") else "—"
704
+ tipo = ev.get("extendedProps", {}).get("tipo", "—")
705
+ st.write(f"• **{fps}** — **{tipo}** ({formatar_data_br(data_consulta)})")
706
+
707
+ finally:
708
+ db.close()
calendario_mensal.py ADDED
@@ -0,0 +1,70 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ import calendar
3
+ from datetime import date
4
+ from banco import SessionLocal
5
+ from models import EventoCalendario
6
+ from utils_permissoes import verificar_permissao
7
+ from utils_datas import formatar_data_br
8
+
9
+
10
+ def main():
11
+
12
+ if not verificar_permissao("calendario"):
13
+ st.error("⛔ Acesso não autorizado.")
14
+ return
15
+
16
+ usuario = st.session_state.get("usuario")
17
+ if not usuario:
18
+ st.error("Usuário não autenticado.")
19
+ return
20
+
21
+ st.title("📆 Agenda Mensal")
22
+
23
+ hoje = date.today()
24
+
25
+ col1, col2 = st.columns(2)
26
+
27
+ with col1:
28
+ ano = st.selectbox("Ano", range(hoje.year - 2, hoje.year + 3), index=2)
29
+
30
+ with col2:
31
+ mes = st.selectbox("Mês", range(1, 13), index=hoje.month - 1)
32
+
33
+ db = SessionLocal()
34
+ try:
35
+ eventos = (
36
+ db.query(EventoCalendario)
37
+ .filter(EventoCalendario.usuario_criacao == usuario)
38
+ .filter(EventoCalendario.data_evento.between(
39
+ date(ano, mes, 1),
40
+ date(ano, mes, calendar.monthrange(ano, mes)[1])
41
+ ))
42
+ .filter(EventoCalendario.ativo.is_(True))
43
+ .all()
44
+ )
45
+ finally:
46
+ db.close()
47
+
48
+ eventos_por_dia = {}
49
+ for e in eventos:
50
+ eventos_por_dia.setdefault(e.data_evento.day, []).append(e)
51
+
52
+ st.divider()
53
+
54
+ semanas = calendar.monthcalendar(ano, mes)
55
+
56
+ dias_semana = ["Seg", "Ter", "Qua", "Qui", "Sex", "Sáb", "Dom"]
57
+ st.columns(7)
58
+ for d in dias_semana:
59
+ st.markdown(f"**{d}**")
60
+
61
+ for semana in semanas:
62
+ cols = st.columns(7)
63
+ for idx, dia in enumerate(semana):
64
+ with cols[idx]:
65
+ if dia == 0:
66
+ st.write("")
67
+ else:
68
+ st.markdown(f"### {dia}")
69
+ for ev in eventos_por_dia.get(dia, []):
70
+ st.caption(f"📌 {ev.titulo}")
componentes.py ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+
3
+
4
+ FPSO_PADRAO = [
5
+ "CDA",
6
+ "CDP",
7
+ "CDM",
8
+ "ADG",
9
+ "ESS",
10
+ "SEP",
11
+ "CDI",
12
+ "ATD",
13
+ "CDS"
14
+ ]
15
+
16
+
17
+ def campo_fpso(label, key):
18
+ """
19
+ Campo FPSO com sugestões + opção de texto livre
20
+ """
21
+ opcoes = [""] + FPSO_PADRAO + ["Outro"]
22
+
23
+ escolha = st.selectbox(
24
+ label,
25
+ opcoes,
26
+ key=f"{key}_select"
27
+ )
28
+
29
+ if escolha == "Outro":
30
+ return st.text_input(
31
+ f"{label} (digite)",
32
+ key=f"{key}_texto"
33
+ ).strip()
34
+
35
+ return escolha
consulta.py ADDED
@@ -0,0 +1,277 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import streamlit as st
3
+ import pandas as pd
4
+ from io import BytesIO
5
+ from datetime import date
6
+ from banco import SessionLocal
7
+ from models import Equipamento
8
+
9
+
10
+ def limpar_estado_consulta():
11
+ """
12
+ Remove do session_state qualquer dado
13
+ relacionado ao módulo Consulta
14
+ """
15
+ for key in list(st.session_state.keys()):
16
+ if key.startswith("consulta_"):
17
+ del st.session_state[key]
18
+
19
+
20
+ def _coerce_date(x):
21
+ """Garante que valores sejam datas (date) ou NaT para comparação."""
22
+ if pd.isna(x):
23
+ return pd.NaT
24
+ if isinstance(x, (pd.Timestamp, )):
25
+ return x.date()
26
+ if isinstance(x, date):
27
+ return x
28
+ try:
29
+ return pd.to_datetime(x).date()
30
+ except Exception:
31
+ return pd.NaT
32
+
33
+
34
+ def main():
35
+
36
+ # =====================================================
37
+ # 🧹 LIMPA ESTADO AO ENTRAR NO MÓDULO
38
+ # =====================================================
39
+ if not st.session_state.get("_consulta_inicializado"):
40
+ limpar_estado_consulta()
41
+ st.session_state["_consulta_inicializado"] = True
42
+
43
+ st.title("🔍 Consulta de Registros")
44
+
45
+ db = SessionLocal()
46
+
47
+ try:
48
+ registros = db.query(Equipamento).all()
49
+
50
+ if not registros:
51
+ st.info("Nenhum registro encontrado.")
52
+ return
53
+
54
+ # =====================================================
55
+ # 🔄 CONVERTE REGISTROS EM DATAFRAME (TODOS OS CAMPOS)
56
+ # =====================================================
57
+ df = pd.DataFrame([
58
+ {
59
+ "ID": r.id,
60
+
61
+ # Identificação
62
+ "FPSO1": r.fpso1,
63
+ "FPSO": r.fpso,
64
+ "Data Coleta": r.data_coleta,
65
+
66
+ # Responsáveis
67
+ "Especialista": r.especialista,
68
+ "Conferente": r.conferente,
69
+ "OSM": r.osm,
70
+
71
+ # Operacional
72
+ "Modal": r.modal,
73
+ "Quantidade Equip.": r.quant_equip,
74
+ "MROB": r.mrob,
75
+
76
+ # Métricas
77
+ "Linhas OSM": r.linhas_osm,
78
+ "Linhas MROB": r.linhas_mrob,
79
+ "Linhas Erros": r.linhas_erros,
80
+
81
+ # Erros
82
+ "Erro Storekeeper": r.erro_storekeeper,
83
+ "Erro Operação": r.erro_operacao,
84
+ "Erro Especialista": r.erro_especialista,
85
+ "Erro Outros": r.erro_outros,
86
+
87
+ # Dados complementares
88
+ "Inclusão / Exclusão": r.inclusao_exclusao,
89
+ "PO": r.po,
90
+ "Part Number": r.part_number,
91
+ "Material": r.material,
92
+
93
+ "Solicitante": r.solicitante,
94
+ "Motivo": getattr(r, "motivo", None),
95
+ "Requisitante": r.requisitante,
96
+ "Nota Fiscal": r.nota_fiscal,
97
+ "Impacto": r.impacto,
98
+ "Dimensão": r.dimensao,
99
+
100
+ "Observações": r.observacoes,
101
+ "Dia Inclusão": r.dia_inclusao,
102
+
103
+ # Auditoria
104
+ "Data/Hora Input": r.data_hora_input,
105
+ }
106
+ for r in registros
107
+ ])
108
+
109
+ # Normaliza a coluna de data para comparação correta
110
+ if "Data Coleta" in df.columns:
111
+ df["Data Coleta"] = df["Data Coleta"].apply(_coerce_date)
112
+
113
+ # =====================================================
114
+ # 🔎 FILTROS
115
+ # =====================================================
116
+ st.subheader("🔎 Filtros")
117
+
118
+ col1, col2, col3 = st.columns(3)
119
+
120
+ with col1:
121
+ filtro_fpso = st.multiselect(
122
+ "FPSO",
123
+ sorted(df["FPSO"].dropna().unique()),
124
+ key="consulta_fpso"
125
+ )
126
+
127
+ filtro_dia = st.multiselect(
128
+ "Dia de Inclusão (D1 / D2 / D3)",
129
+ sorted(df["Dia Inclusão"].dropna().unique()),
130
+ key="consulta_dia"
131
+ )
132
+
133
+ with col2:
134
+ filtro_modal = st.multiselect(
135
+ "Modal",
136
+ sorted(df["Modal"].dropna().unique()),
137
+ key="consulta_modal"
138
+ )
139
+
140
+ filtro_especialista = st.multiselect(
141
+ "Especialista",
142
+ sorted(df["Especialista"].dropna().unique()),
143
+ key="consulta_especialista"
144
+ )
145
+
146
+ # 🔵 FILTRO OSM
147
+ filtro_osm = st.multiselect(
148
+ "OSM",
149
+ sorted(df["OSM"].dropna().unique()),
150
+ key="consulta_osm"
151
+ )
152
+
153
+ with col3:
154
+ periodo = st.date_input(
155
+ "Período de Coleta",
156
+ value=None,
157
+ key="consulta_periodo"
158
+ )
159
+
160
+ # 🟩 NOVO: FILTRO DE NOTA FISCAL
161
+ st.markdown("**Nota Fiscal**")
162
+ nota_input_text = st.text_input(
163
+ "Digite um ou mais números (separados por vírgula)",
164
+ value="",
165
+ key="consulta_nf_text"
166
+ )
167
+ # Alternativamente (opcional) oferecer multiselect pelos valores existentes
168
+ filtro_nf_multi = st.multiselect(
169
+ "Ou selecione",
170
+ sorted([str(x) for x in df["Nota Fiscal"].dropna().unique()]),
171
+ key="consulta_nf_multi"
172
+ )
173
+ mostrar_apenas_duplicadas = st.checkbox(
174
+ "Mostrar apenas notas duplicadas",
175
+ value=False,
176
+ key="consulta_mostrar_dup_nf"
177
+ )
178
+
179
+ # =====================================================
180
+ # 🔄 APLICA FILTROS
181
+ # =====================================================
182
+ # Filtros simples
183
+ if filtro_fpso:
184
+ df = df[df["FPSO"].isin(filtro_fpso)]
185
+
186
+ if filtro_modal:
187
+ df = df[df["Modal"].isin(filtro_modal)]
188
+
189
+ if filtro_especialista:
190
+ df = df[df["Especialista"].isin(filtro_especialista)]
191
+
192
+ if filtro_dia:
193
+ df = df[df["Dia Inclusão"].isin(filtro_dia)]
194
+
195
+ if filtro_osm:
196
+ df = df[df["OSM"].isin(filtro_osm)]
197
+
198
+ # Filtro de período (intervalo)
199
+ if isinstance(periodo, (list, tuple)) and len(periodo) == 2 and all(periodo):
200
+ data_inicio, data_fim = periodo
201
+ df = df[
202
+ (df["Data Coleta"] >= data_inicio) &
203
+ (df["Data Coleta"] <= data_fim)
204
+ ]
205
+
206
+ # -----------------------------------------------------
207
+ # Filtro de Nota Fiscal (texto e/ou multiselect)
208
+ # -----------------------------------------------------
209
+ # Consolida as notas informadas via texto (separadas por vírgula)
210
+ notas_texto = []
211
+ if nota_input_text.strip():
212
+ notas_texto = [x.strip() for x in nota_input_text.split(",") if x.strip()]
213
+
214
+ # Concatena com o multiselect (transformando em string)
215
+ notas_escolhidas = set([str(x) for x in filtro_nf_multi] + [str(x) for x in notas_texto])
216
+
217
+ if notas_escolhidas:
218
+ # Comparar sempre como string para evitar problemas com zeros à esquerda ou tipos heterogêneos
219
+ df = df[df["Nota Fiscal"].astype(str).isin(notas_escolhidas)]
220
+
221
+ # =====================================================
222
+ # 🧭 SINALIZA DUPLICIDADE DE NOTA FISCAL
223
+ # =====================================================
224
+ # Conta ocorrências por número (string) ignorando NaN
225
+ nf_series = df["Nota Fiscal"].astype(str).fillna("")
226
+ contagem_nf = nf_series.value_counts(dropna=False)
227
+ # Duplicadas são as que tem contagem > 1 (e não vazias)
228
+ notas_duplicadas = contagem_nf[(contagem_nf > 1) & (contagem_nf.index != "")]
229
+
230
+ # Coluna booleana marcando duplicidade no DF atual
231
+ df["Duplicidade Nota"] = df["Nota Fiscal"].astype(str).isin(notas_duplicadas.index)
232
+
233
+ # Aviso resumido
234
+ if len(notas_duplicadas) > 0:
235
+ st.warning(
236
+ f"⚠️ Foram encontradas **{int(notas_duplicadas.sum())}** ocorrências em **{len(notas_duplicadas)}** "
237
+ f"números de Nota Fiscal duplicados no resultado."
238
+ )
239
+ with st.expander("Ver lista de notas duplicadas"):
240
+ dup_df = pd.DataFrame({
241
+ "Nota Fiscal": notas_duplicadas.index,
242
+ "Ocorrências": notas_duplicadas.values
243
+ }).sort_values(by="Ocorrências", ascending=False)
244
+ st.dataframe(dup_df, use_container_width=True)
245
+
246
+ # Mostrar apenas duplicadas, caso marcado
247
+ if mostrar_apenas_duplicadas:
248
+ df = df[df["Duplicidade Nota"] == True]
249
+
250
+ # =====================================================
251
+ # 📊 RESULTADOS
252
+ # =====================================================
253
+ st.subheader("📊 Resultados")
254
+ st.caption("A coluna **Duplicidade Nota** indica se há mais de um registro com o mesmo número de Nota Fiscal no resultado atual.")
255
+ st.dataframe(df, use_container_width=True)
256
+
257
+ # =====================================================
258
+ # 📥 EXPORTAÇÃO EXCEL
259
+ # =====================================================
260
+ buffer = BytesIO()
261
+ with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
262
+ df.to_excel(writer, index=False, sheet_name="Consulta")
263
+
264
+ buffer.seek(0)
265
+
266
+ st.download_button(
267
+ label="⬇️ Exportar para Excel",
268
+ data=buffer,
269
+ file_name="consulta_equipamentos.xlsx",
270
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
271
+ )
272
+
273
+ finally:
274
+ db.close()
275
+
276
+
277
+
db_admin.py ADDED
@@ -0,0 +1,387 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # db_admin.py
3
+ import streamlit as st
4
+ import os
5
+ import shutil
6
+ from sqlalchemy import text
7
+ from banco import engine, SessionLocal
8
+ from utils_permissoes import verificar_permissao
9
+ from utils_auditoria import registrar_log
10
+
11
+ # =====================================================
12
+ # MÓDULO GERAL DE ADMINISTRAÇÃO DE BANCO (SCHEMA)
13
+ # =====================================================
14
+ # Objetivo:
15
+ # - Permitir adicionar, renomear, excluir e alterar tipo de colunas via UI
16
+ # - Funciona com SQLite, PostgreSQL e MySQL (com diferenças por dialeto)
17
+ # - Em SQLite, oferece reconstrução assistida quando DDL não é suportado
18
+ #
19
+ # Segurança e boas práticas:
20
+ # - Recomendado fazer backup antes de operações (botão disponível para SQLite)
21
+ # - Operações DDL são críticas: exigir confirmação explícita
22
+ # - Acesso restrito ao perfil "admin"
23
+ #
24
+ # Logs:
25
+ # - registrar_log(...) é chamado em todas as operações
26
+
27
+
28
+ # -------------------------
29
+ # Utilitário: Dialeto e versão
30
+ # -------------------------
31
+ def _dialeto():
32
+ try:
33
+ return engine.url.get_backend_name()
34
+ except Exception:
35
+ return "desconhecido"
36
+
37
+ def _sqlite_version():
38
+ if _dialeto() != "sqlite":
39
+ return None
40
+ try:
41
+ with engine.begin() as conn:
42
+ rv = conn.execute(text("select sqlite_version()")).scalar()
43
+ return rv
44
+ except Exception:
45
+ return None
46
+
47
+
48
+ # -------------------------
49
+ # Utilitário: Listar tabelas e colunas
50
+ # -------------------------
51
+ def _listar_tabelas():
52
+ d = _dialeto()
53
+ with engine.begin() as conn:
54
+ if d == "sqlite":
55
+ rows = conn.execute(text("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")).fetchall()
56
+ return [r[0] for r in rows]
57
+ else:
58
+ q = text("""
59
+ SELECT table_name
60
+ FROM information_schema.tables
61
+ WHERE table_schema NOT IN ('pg_catalog','information_schema')
62
+ ORDER BY table_name
63
+ """)
64
+ rows = conn.execute(q).fetchall()
65
+ return [r[0] for r in rows]
66
+
67
+ def _listar_colunas(tabela: str):
68
+ d = _dialeto()
69
+ with engine.begin() as conn:
70
+ if d == "sqlite":
71
+ rows = conn.execute(text(f"PRAGMA table_info({tabela})")).fetchall()
72
+ # PRAGMA: (cid, name, type, notnull, dflt_value, pk)
73
+ return [{"name": r[1], "type": r[2], "notnull": bool(r[3]), "default": r[4], "pk": bool(r[5])} for r in rows]
74
+ else:
75
+ q = text("""
76
+ SELECT column_name, data_type, is_nullable, column_default
77
+ FROM information_schema.columns
78
+ WHERE table_name = :tbl
79
+ ORDER BY ordinal_position
80
+ """)
81
+ rows = conn.execute(q, {"tbl": tabela}).fetchall()
82
+ return [{"name": r[0], "type": r[1], "notnull": (str(r[2]).upper() == "NO"), "default": r[3], "pk": False} for r in rows]
83
+
84
+
85
+ # -------------------------
86
+ # Backup rápido (SQLite)
87
+ # -------------------------
88
+ def _sqlite_backup():
89
+ if _dialeto() != "sqlite":
90
+ st.info("Backup automático só disponível para SQLite via cópia de arquivo.")
91
+ return
92
+ db_path = engine.url.database
93
+ if not db_path or not os.path.exists(db_path):
94
+ st.error("Arquivo de banco SQLite não encontrado.")
95
+ return
96
+ dest = db_path + ".bak"
97
+ shutil.copyfile(db_path, dest)
98
+ st.success(f"Backup criado: {dest}")
99
+
100
+
101
+ # -------------------------
102
+ # DDL: Gerar comandos por dialeto
103
+ # -------------------------
104
+ def _ddl_add_column_sql(tabela, col_nome, col_tipo, notnull=False, default=None):
105
+ d = _dialeto()
106
+ nn = "NOT NULL" if notnull else "NULL"
107
+ def_clause = f" DEFAULT {default}" if (default is not None and str(default).strip() != "") else ""
108
+ if d == "sqlite":
109
+ # SQLite aceita tipo textual; notnull e default são respeitados no schema
110
+ return f"ALTER TABLE {tabela} ADD COLUMN {col_nome} {col_tipo} {nn}{def_clause};"
111
+ elif d in ("postgresql", "postgres"):
112
+ base = f'ALTER TABLE "{tabela}" ADD COLUMN "{col_nome}" {col_tipo}'
113
+ if default is not None and str(default).strip() != "":
114
+ base += f" DEFAULT {default}"
115
+ if notnull:
116
+ base += " NOT NULL"
117
+ return base + ";"
118
+ elif d in ("mysql", "mariadb"):
119
+ base = f"ALTER TABLE `{tabela}` ADD COLUMN `{col_nome}` {col_tipo}"
120
+ if default is not None and str(default).strip() != "":
121
+ base += f" DEFAULT {default}"
122
+ base += " NOT NULL" if notnull else " NULL"
123
+ return base + ";"
124
+ return None
125
+
126
+ def _ddl_rename_column_sql(tabela, old, new):
127
+ d = _dialeto()
128
+ if d == "sqlite":
129
+ return f"ALTER TABLE {tabela} RENAME COLUMN {old} TO {new};"
130
+ elif d in ("postgresql", "postgres"):
131
+ return f'ALTER TABLE "{tabela}" RENAME COLUMN "{old}" TO "{new}";'
132
+ elif d in ("mysql", "mariadb"):
133
+ # MySQL requer tipo na renomeação; esta função não cobre tipo -> usar CHANGE COLUMN via UI de "Alterar tipo/renomear"
134
+ return None
135
+ return None
136
+
137
+ def _ddl_drop_column_sql(tabela, col):
138
+ d = _dialeto()
139
+ if d == "sqlite":
140
+ return f"ALTER TABLE {tabela} DROP COLUMN {col};"
141
+ elif d in ("postgresql", "postgres"):
142
+ return f'ALTER TABLE "{tabela}" DROP COLUMN "{col}";'
143
+ elif d in ("mysql", "mariadb"):
144
+ return f"ALTER TABLE `{tabela}` DROP COLUMN `{col}`;"
145
+ return None
146
+
147
+ def _ddl_alter_type_sql(tabela, col, new_type):
148
+ d = _dialeto()
149
+ if d == "sqlite":
150
+ # SQLite não altera type declarado via ALTER TYPE. Necessário reconstruir tabela.
151
+ return None
152
+ elif d in ("postgresql", "postgres"):
153
+ return f'ALTER TABLE "{tabela}" ALTER COLUMN "{col}" TYPE {new_type};'
154
+ elif d in ("mysql", "mariadb"):
155
+ return f"ALTER TABLE `{tabela}` MODIFY COLUMN `{col}` {new_type};"
156
+ return None
157
+
158
+
159
+ # -------------------------
160
+ # Reconstrução assistida (SQLite)
161
+ # -------------------------
162
+ def _sqlite_reconstruir_tabela(tabela, novas_colunas):
163
+ """
164
+ Reconstrói tabela SQLite com "novas_colunas" (lista de dicts):
165
+ [{"name":..., "type":..., "notnull":bool, "default":..., "pk":bool}, ...]
166
+ - Cria tabela __tmp_<tabela> com o novo schema
167
+ - Copia dados das colunas compatíveis (mesmos nomes)
168
+ - Drop da tabela original e rename da temporária
169
+ """
170
+ cols_def = []
171
+ copy_cols = []
172
+ pk_cols = [c["name"] for c in novas_colunas if c.get("pk")]
173
+ for c in novas_colunas:
174
+ nn = "NOT NULL" if c.get("notnull") else ""
175
+ default = c.get("default")
176
+ def_clause = f" DEFAULT {default}" if (default is not None and str(default).strip() != "") else ""
177
+ cols_def.append(f'{c["name"]} {c["type"]} {nn}{def_clause}'.strip())
178
+ copy_cols.append(c["name"])
179
+ pk_clause = f", PRIMARY KEY ({', '.join(pk_cols)})" if pk_cols else ""
180
+
181
+ create_sql = f"CREATE TABLE __tmp_{tabela} ({', '.join(cols_def)}{pk_clause});"
182
+ copy_sql = f"INSERT INTO __tmp_{tabela} ({', '.join(copy_cols)}) SELECT {', '.join(copy_cols)} FROM {tabela};"
183
+ drop_sql = f"DROP TABLE {tabela};"
184
+ rename_sql= f"ALTER TABLE __tmp_{tabela} RENAME TO {tabela};"
185
+
186
+ with engine.begin() as conn:
187
+ conn.execute(text(create_sql))
188
+ conn.execute(text(copy_sql))
189
+ conn.execute(text(drop_sql))
190
+ conn.execute(text(rename_sql))
191
+
192
+
193
+ # -------------------------
194
+ # UI principal (admin)
195
+ # -------------------------
196
+ def main():
197
+ st.title("🛠️ Administração de Banco (Schema)")
198
+
199
+ # 🔐 Proteção por perfil
200
+ if not verificar_permissao("db_admin") and st.session_state.get("perfil") != "admin":
201
+ st.error("⛔ Acesso não autorizado.")
202
+ return
203
+
204
+ # Info do banco
205
+ dial = _dialeto()
206
+ st.caption(f"Dialeto: **{dial}**")
207
+ ver = _sqlite_version()
208
+ if ver:
209
+ st.caption(f"SQLite version: **{ver}**")
210
+
211
+ # Backup (SQLite)
212
+ if dial == "sqlite":
213
+ if st.button("💾 Backup rápido (SQLite)"):
214
+ _sqlite_backup()
215
+
216
+ # Tabelas disponíveis
217
+ tabelas = _listar_tabelas()
218
+ if not tabelas:
219
+ st.warning("Nenhuma tabela encontrada.")
220
+ return
221
+
222
+ tabela = st.selectbox("Tabela alvo:", tabelas, index=0)
223
+ colunas = _listar_colunas(tabela)
224
+
225
+ st.divider()
226
+ st.subheader("📋 Colunas atuais")
227
+ st.write(pd.DataFrame(colunas)) if 'pd' in globals() else st.write(colunas) # mostra estrutura atual
228
+
229
+ st.divider()
230
+ tabs = st.tabs(["➕ Adicionar coluna", "✏️ Renomear coluna", "🗑️ Excluir coluna", "♻️ Alterar tipo"])
231
+
232
+ # ----------------- Adicionar coluna -----------------
233
+ with tabs[0]:
234
+ st.markdown("**Adicionar uma nova coluna à tabela selecionada**")
235
+ novo_nome = st.text_input("Nome da nova coluna")
236
+ novo_tipo = st.text_input("Tipo (ex.: TEXT, INTEGER, VARCHAR(255))")
237
+ novo_notnull = st.checkbox("NOT NULL", value=False)
238
+ novo_default = st.text_input("DEFAULT (opcional)")
239
+
240
+ confirmar_add = st.checkbox("Confirmo a adição desta coluna (DDL).")
241
+ if st.button("Executar ADD COLUMN", type="primary") and confirmar_add:
242
+ sql = _ddl_add_column_sql(tabela, novo_nome, novo_tipo, notnull=novo_notnull, default=novo_default)
243
+ if not sql:
244
+ st.error("Dialeto não suportado para ADD COLUMN.")
245
+ else:
246
+ try:
247
+ with engine.begin() as conn:
248
+ conn.execute(text(sql))
249
+ registrar_log(st.session_state.get("usuario"), f"ADD COLUMN {novo_nome} {novo_tipo} em {tabela}", "schema", None)
250
+ st.success("✅ Coluna adicionada com sucesso.")
251
+ st.rerun()
252
+ except Exception as e:
253
+ st.error(f"Erro ao adicionar coluna: {e}")
254
+
255
+ # ----------------- Renomear coluna -----------------
256
+ with tabs[1]:
257
+ st.markdown("**Renomear uma coluna existente**")
258
+ col_nomes = [c["name"] for c in colunas]
259
+ antigo = st.selectbox("Coluna atual:", col_nomes) if col_nomes else ""
260
+ novo = st.text_input("Novo nome da coluna")
261
+
262
+ confirmar_ren = st.checkbox("Confirmo a renomeação desta coluna (DDL).")
263
+ if st.button("Executar RENAME COLUMN") and confirmar_ren:
264
+ d = _dialeto()
265
+ if d == "sqlite":
266
+ # Verifica suporte na versão
267
+ ver = _sqlite_version() or "0.0.0"
268
+ suportado = tuple(map(int, ver.split("."))) >= (3, 25, 0)
269
+ if suportado:
270
+ sql = _ddl_rename_column_sql(tabela, antigo, novo)
271
+ try:
272
+ with engine.begin() as conn:
273
+ conn.execute(text(sql))
274
+ registrar_log(st.session_state.get("usuario"), f"RENAME COLUMN {antigo}→{novo} em {tabela}", "schema", None)
275
+ st.success("✅ Coluna renomeada com sucesso.")
276
+ st.rerun()
277
+ except Exception as e:
278
+ st.error(f"Erro ao renomear: {e}")
279
+ else:
280
+ st.warning("SQLite < 3.25 não suporta RENAME COLUMN. Oferecendo reconstrução assistida.")
281
+ # Reconstrução: atualiza metadados e recria tabela
282
+ novas = []
283
+ for c in colunas:
284
+ nm = novo if c["name"] == antigo else c["name"]
285
+ novas.append({"name": nm, "type": c["type"], "notnull": c["notnull"], "default": c["default"], "pk": c["pk"]})
286
+ try:
287
+ _sqlite_reconstruir_tabela(tabela, novas)
288
+ registrar_log(st.session_state.get("usuario"), f"RENAME (rebuild) {antigo}→{novo} em {tabela}", "schema", None)
289
+ st.success("✅ Reconstrução concluída com sucesso.")
290
+ st.rerun()
291
+ except Exception as e:
292
+ st.error(f"Erro na reconstrução: {e}")
293
+ elif d in ("postgresql", "postgres"):
294
+ sql = _ddl_rename_column_sql(tabela, antigo, novo)
295
+ if not sql:
296
+ st.error("Renomeação não suportada.")
297
+ else:
298
+ try:
299
+ with engine.begin() as conn:
300
+ conn.execute(text(sql))
301
+ registrar_log(st.session_state.get("usuario"), f"RENAME COLUMN {antigo}→{novo} em {tabela}", "schema", None)
302
+ st.success("✅ Coluna renomeada com sucesso.")
303
+ st.rerun()
304
+ except Exception as e:
305
+ st.error(f"Erro ao renomear: {e}")
306
+ elif d in ("mysql", "mariadb"):
307
+ st.info("MySQL/MariaDB exigem 'CHANGE COLUMN' informando o novo tipo; use a aba 'Alterar tipo' para renomear junto com tipo.")
308
+
309
+ # ----------------- Excluir coluna -----------------
310
+ with tabs[2]:
311
+ st.markdown("**Excluir uma coluna existente**")
312
+ col_nomes = [c["name"] for c in colunas]
313
+ col_drop = st.selectbox("Coluna a excluir:", col_nomes) if col_nomes else ""
314
+
315
+ confirmar_drop = st.checkbox("Confirmo a exclusão desta coluna (DDL) e entendo que é irreversível.")
316
+ if st.button("Executar DROP COLUMN", type="secondary") and confirmar_drop:
317
+ d = _dialeto()
318
+ if d == "sqlite":
319
+ ver = _sqlite_version() or "0.0.0"
320
+ suportado = tuple(map(int, ver.split("."))) >= (3, 35, 0)
321
+ if suportado:
322
+ sql = _ddl_drop_column_sql(tabela, col_drop)
323
+ try:
324
+ with engine.begin() as conn:
325
+ conn.execute(text(sql))
326
+ registrar_log(st.session_state.get("usuario"), f"DROP COLUMN {col_drop} em {tabela}", "schema", None)
327
+ st.success("✅ Coluna excluída com sucesso.")
328
+ st.rerun()
329
+ except Exception as e:
330
+ st.error(f"Erro ao excluir: {e}")
331
+ else:
332
+ st.warning("SQLite < 3.35 não suporta DROP COLUMN. Oferecendo reconstrução assistida.")
333
+ novas = [c for c in colunas if c["name"] != col_drop]
334
+ try:
335
+ _sqlite_reconstruir_tabela(tabela, novas)
336
+ registrar_log(st.session_state.get("usuario"), f"DROP (rebuild) {col_drop} em {tabela}", "schema", None)
337
+ st.success("✅ Reconstrução concluída e coluna removida.")
338
+ st.rerun()
339
+ except Exception as e:
340
+ st.error(f"Erro na reconstrução: {e}")
341
+ elif d in ("postgresql", "postgres", "mysql", "mariadb"):
342
+ sql = _ddl_drop_column_sql(tabela, col_drop)
343
+ try:
344
+ with engine.begin() as conn:
345
+ conn.execute(text(sql))
346
+ registrar_log(st.session_state.get("usuario"), f"DROP COLUMN {col_drop} em {tabela}", "schema", None)
347
+ st.success("✅ Coluna excluída com sucesso.")
348
+ st.rerun()
349
+ except Exception as e:
350
+ st.error(f"Erro ao excluir: {e}")
351
+
352
+ # ----------------- Alterar tipo -----------------
353
+ with tabs[3]:
354
+ st.markdown("**Alterar tipo declarado de uma coluna**")
355
+ col_nomes = [c["name"] for c in colunas]
356
+ alvo = st.selectbox("Coluna alvo:", col_nomes) if col_nomes else ""
357
+ novo_tipo = st.text_input("Novo tipo (ex.: TEXT, INTEGER, VARCHAR(255))")
358
+
359
+ confirmar_type = st.checkbox("Confirmo a alteração de tipo (DDL).")
360
+ if st.button("Executar ALTER TYPE") and confirmar_type:
361
+ d = _dialeto()
362
+ if d == "sqlite":
363
+ st.warning("SQLite não suporta ALTER TYPE direto; oferecemos reconstrução assistida.")
364
+ novas = []
365
+ for c in colunas:
366
+ typ = novo_tipo if c["name"] == alvo else c["type"]
367
+ novas.append({"name": c["name"], "type": typ, "notnull": c["notnull"], "default": c["default"], "pk": c["pk"]})
368
+ try:
369
+ _sqlite_reconstruir_tabela(tabela, novas)
370
+ registrar_log(st.session_state.get("usuario"), f"ALTER TYPE (rebuild) {alvo}→{novo_tipo} em {tabela}", "schema", None)
371
+ st.success("✅ Tipo alterado com sucesso via reconstrução.")
372
+ st.rerun()
373
+ except Exception as e:
374
+ st.error(f"Erro na reconstrução: {e}")
375
+ elif d in ("postgresql", "postgres", "mysql", "mariadb"):
376
+ sql = _ddl_alter_type_sql(tabela, alvo, novo_tipo)
377
+ if not sql:
378
+ st.error("Dialeto não suportado para ALTER TYPE.")
379
+ else:
380
+ try:
381
+ with engine.begin() as conn:
382
+ conn.execute(text(sql))
383
+ registrar_log(st.session_state.get("usuario"), f"ALTER TYPE {alvo}→{novo_tipo} em {tabela}", "schema", None)
384
+ st.success("✅ Tipo alterado com sucesso.")
385
+ st.rerun()
386
+ except Exception as e:
387
+ st.error(f"Erro ao alterar tipo: {e}")
db_export_import.py ADDED
@@ -0,0 +1,359 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ db_export_import.py — Backup & Restore (Export/Import) do banco ATIVO (Produção/Teste)
5
+
6
+ Recursos:
7
+ • Exibe banco ativo (prod/test) e URL do engine
8
+ • Exporta todas as tabelas para:
9
+ - ZIP (CSV por tabela + manifest.json)
10
+ - Excel (.xlsx) (1 aba por tabela + manifest sheet)
11
+ • Importa (upload) de:
12
+ - ZIP (CSV por tabela)
13
+ - Excel (.xlsx)
14
+ • Modos de import: APPEND ou REPLACE (cuidado com FK)
15
+ • Snapshot físico para SQLite: cópia do arquivo (.db) — backup/restore rápido
16
+
17
+ Dependências:
18
+ - pandas, openpyxl, sqlalchemy, zipfile, io, json, datetime
19
+ """
20
+
21
+ import os
22
+ import io
23
+ import json
24
+ import zipfile
25
+ from datetime import datetime
26
+
27
+ import streamlit as st
28
+ import pandas as pd
29
+ from sqlalchemy import inspect, text
30
+
31
+ from banco import get_engine, db_info, SessionLocal
32
+ from utils_auditoria import registrar_log
33
+
34
+ # Ambiente (prod/test) — se db_router não existir, fallback para 'prod'
35
+ try:
36
+ from db_router import current_db_choice
37
+ _HAS_ROUTER = True
38
+ except Exception:
39
+ _HAS_ROUTER = False
40
+
41
+ def current_db_choice() -> str:
42
+ return "prod"
43
+
44
+
45
+ # =========================
46
+ # Helpers: tabelas e I/O
47
+ # =========================
48
+ def list_tables(engine) -> list[str]:
49
+ """Retorna nomes de todas as tabelas via SQLAlchemy inspection."""
50
+ inspector = inspect(engine)
51
+ return inspector.get_table_names()
52
+
53
+
54
+ def _read_table_df(engine, table_name: str) -> pd.DataFrame:
55
+ """Lê toda a tabela como DataFrame."""
56
+ try:
57
+ # pandas + SQLAlchemy: lê tabela diretamente
58
+ return pd.read_sql_table(table_name, con=engine)
59
+ except Exception:
60
+ # fallback: SELECT com aspas (útil para SQLite com nomes case-sensitive)
61
+ return pd.read_sql(f'SELECT * FROM "{table_name}"', con=engine)
62
+
63
+
64
+ def _write_table_df(engine, table_name: str, df: pd.DataFrame, mode: str = "append"):
65
+ """
66
+ Escreve DataFrame em tabela.
67
+ mode: "append" (adiciona) ou "replace" (sobrescreve todos os dados).
68
+ Observação: nos fluxos de import, quando 'replace' foi selecionado,
69
+ todas as tabelas são truncadas previamente, e aqui usamos 'append'.
70
+ """
71
+ if mode not in ("append", "replace"):
72
+ mode = "append"
73
+ df.to_sql(table_name, con=engine, if_exists=mode, index=False)
74
+
75
+
76
+ # =========================
77
+ # Export: ZIP (CSV) & Excel
78
+ # =========================
79
+ def export_zip(engine, ambiente: str) -> bytes:
80
+ """
81
+ Exporta todas as tabelas para um ZIP:
82
+ - 1 CSV por tabela (UTF-8-BOM)
83
+ - manifest.json com metadados (ambiente, timestamp, url, tabelas)
84
+ """
85
+ tables = list_tables(engine)
86
+ buf = io.BytesIO()
87
+ with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as z:
88
+ for t in tables:
89
+ df = _read_table_df(engine, t)
90
+ csv_bytes = df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig")
91
+ z.writestr(f"{t}.csv", csv_bytes)
92
+
93
+ manifest = {
94
+ "ambiente": ambiente,
95
+ "timestamp": datetime.now().isoformat(),
96
+ "engine_url": str(engine.url),
97
+ "tables": tables,
98
+ "format": "zip/csv",
99
+ "version": "1.0",
100
+ }
101
+ z.writestr("manifest.json", json.dumps(manifest, ensure_ascii=False, indent=2))
102
+ buf.seek(0)
103
+ return buf.getvalue()
104
+
105
+
106
+ def export_excel(engine, ambiente: str) -> bytes:
107
+ """
108
+ Exporta todas as tabelas para um Excel (.xlsx):
109
+ - 1 aba por tabela (limitada a 31 caracteres)
110
+ - "manifest" com metadados
111
+ """
112
+ tables = list_tables(engine)
113
+ buf = io.BytesIO()
114
+ with pd.ExcelWriter(buf, engine="openpyxl") as writer:
115
+ # manifest
116
+ manifest = pd.DataFrame([{
117
+ "ambiente": ambiente,
118
+ "timestamp": datetime.now().isoformat(),
119
+ "engine_url": str(engine.url),
120
+ "tables": ", ".join(tables),
121
+ "format": "xlsx",
122
+ "version": "1.0",
123
+ }])
124
+ manifest.to_excel(writer, sheet_name="manifest", index=False)
125
+
126
+ # tabelas → 1 aba por tabela
127
+ for t in tables:
128
+ df = _read_table_df(engine, t)
129
+ sheet = t[:31] if len(t) > 31 else t
130
+ df.to_excel(writer, sheet_name=sheet, index=False)
131
+ buf.seek(0)
132
+ return buf.getvalue()
133
+
134
+
135
+ # =========================
136
+ # Import: ZIP (CSV) & Excel
137
+ # =========================
138
+ def import_zip(engine, file_bytes: bytes, mode: str = "append") -> dict:
139
+ """
140
+ Importa dados de um ZIP (CSV por tabela).
141
+ mode: "append" ou "replace".
142
+ Retorna um relatório {table: {"rows": int, "mode": str}}.
143
+ """
144
+ report = {}
145
+ zbuf = io.BytesIO(file_bytes)
146
+ with zipfile.ZipFile(zbuf, "r") as z:
147
+ # Se replace, limpar tabelas (cuidado com FK)
148
+ if mode == "replace":
149
+ _truncate_all(engine)
150
+
151
+ for name in z.namelist():
152
+ if not name.lower().endswith(".csv"):
153
+ continue
154
+ table = os.path.splitext(os.path.basename(name))[0]
155
+ csv_bytes = z.read(name)
156
+ df = pd.read_csv(io.BytesIO(csv_bytes), dtype=str) # dtype=str para evitar coercões agressivas
157
+ # Conversões leves de datetime (best-effort)
158
+ for col in df.columns:
159
+ if "data" in col.lower() or "date" in col.lower():
160
+ try:
161
+ df[col] = pd.to_datetime(df[col], errors="ignore")
162
+ except Exception:
163
+ pass
164
+ # replace já truncou; aqui fazemos append
165
+ _write_table_df(engine, table, df, mode="append")
166
+ report[table] = {"rows": int(len(df)), "mode": mode}
167
+ return report
168
+
169
+
170
+ def import_excel(engine, file_bytes: bytes, mode: str = "append") -> dict:
171
+ """
172
+ Importa dados de um Excel (.xlsx) com múltiplas abas (1 por tabela).
173
+ mode: "append" ou "replace".
174
+ """
175
+ report = {}
176
+ xbuf = io.BytesIO(file_bytes)
177
+ xls = pd.ExcelFile(xbuf, engine="openpyxl")
178
+ sheets = [s for s in xls.sheet_names if s.lower() != "manifest"]
179
+
180
+ if mode == "replace":
181
+ _truncate_all(engine)
182
+
183
+ for sheet in sheets:
184
+ df = xls.parse(sheet_name=sheet, dtype=str)
185
+ # best-effort para datas
186
+ for col in df.columns:
187
+ if "data" in col.lower() or "date" in col.lower():
188
+ try:
189
+ df[col] = pd.to_datetime(df[col], errors="ignore")
190
+ except Exception:
191
+ pass
192
+ table = sheet
193
+ _write_table_df(engine, table, df, mode="append")
194
+ report[table] = {"rows": int(len(df)), "mode": mode}
195
+ return report
196
+
197
+
198
+ # =========================
199
+ # Truncate (REPLACE mode)
200
+ # =========================
201
+ def _truncate_all(engine):
202
+ """
203
+ Limpa todas as tabelas do banco ativo (cuidado!).
204
+ • Para SQLite: desabilita FK temporariamente, apaga, e reabilita.
205
+ • Para outros bancos: executa DELETE tabela; considere ordem por FK se necessário.
206
+ """
207
+ insp = inspect(engine)
208
+ tables = insp.get_table_names()
209
+
210
+ with engine.begin() as conn:
211
+ url = str(engine.url)
212
+ is_sqlite = url.startswith("sqlite")
213
+ if is_sqlite:
214
+ conn.execute(text("PRAGMA foreign_keys=OFF"))
215
+
216
+ # Apaga conteúdo (sem considerar ordem de FK — OK para SQLite com FK OFF)
217
+ for t in tables:
218
+ conn.execute(text(f'DELETE FROM "{t}"'))
219
+
220
+ if is_sqlite:
221
+ conn.execute(text("PRAGMA foreign_keys=ON"))
222
+
223
+
224
+ # =========================
225
+ # Snapshot físico (SQLite)
226
+ # =========================
227
+ def snapshot_sqlite(engine, ambiente: str) -> bytes:
228
+ """
229
+ Cria um snapshot (cópia física) do arquivo SQLite do banco ativo.
230
+ Retorna o conteúdo do arquivo para download.
231
+ """
232
+ url = str(engine.url)
233
+ if not url.startswith("sqlite:///"):
234
+ raise RuntimeError("Snapshot físico disponível apenas para SQLite.")
235
+ db_path = url.replace("sqlite:///", "")
236
+ if not os.path.isfile(db_path):
237
+ raise FileNotFoundError(f"Arquivo SQLite não encontrado: {db_path}")
238
+
239
+ with open(db_path, "rb") as f:
240
+ data = f.read()
241
+
242
+ # auditoria
243
+ try:
244
+ registrar_log(usuario=st.session_state.get("usuario"),
245
+ acao=f"Snapshot SQLite ({ambiente})",
246
+ tabela="backup",
247
+ registro_id=None)
248
+ except Exception:
249
+ pass
250
+
251
+ return data
252
+
253
+
254
+ # =========================
255
+ # UI (Streamlit)
256
+ # =========================
257
+ def main():
258
+ st.title("🗄️ Backup & Restore | Export/Import de Banco")
259
+
260
+ # Banco ativo e info
261
+ ambiente = current_db_choice()
262
+ info = db_info()
263
+ st.caption(f"🧭 Ambiente: {'Produção' if ambiente == 'prod' else 'Teste'}")
264
+ st.caption(f"🔗 Engine URL: {info.get('url')}")
265
+
266
+ engine = get_engine()
267
+
268
+ st.divider()
269
+ st.subheader("⬇️ Exportar dados")
270
+
271
+ colA, colB, colC = st.columns(3)
272
+ with colA:
273
+ if st.button("Exportar ZIP (CSV por tabela)", type="primary"):
274
+ try:
275
+ zip_bytes = export_zip(engine, ambiente)
276
+ fname = f"backup_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.zip"
277
+ st.download_button("📥 Baixar ZIP", data=zip_bytes, file_name=fname, mime="application/zip")
278
+ registrar_log(usuario=st.session_state.get("usuario"),
279
+ acao=f"Export ZIP (ambiente={ambiente})",
280
+ tabela="backup", registro_id=None)
281
+ except Exception as e:
282
+ st.error(f"Falha ao exportar ZIP: {e}")
283
+
284
+ with colB:
285
+ if st.button("Exportar Excel (.xlsx)", type="primary"):
286
+ try:
287
+ xlsx_bytes = export_excel(engine, ambiente)
288
+ fname = f"backup_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.xlsx"
289
+ st.download_button("📥 Baixar Excel", data=xlsx_bytes, file_name=fname,
290
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
291
+ registrar_log(usuario=st.session_state.get("usuario"),
292
+ acao=f"Export XLSX (ambiente={ambiente})",
293
+ tabela="backup", registro_id=None)
294
+ except Exception as e:
295
+ st.error(f"Falha ao exportar Excel: {e}")
296
+
297
+ with colC:
298
+ # Snapshot físico apenas para SQLite
299
+ url = str(engine.url)
300
+ if url.startswith("sqlite:///"):
301
+ if st.button("Snapshot físico (SQLite)", type="secondary"):
302
+ try:
303
+ snap_bytes = snapshot_sqlite(engine, ambiente)
304
+ fname = f"snapshot_{ambiente}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.db"
305
+ st.download_button("📥 Baixar Snapshot (.db)", data=snap_bytes, file_name=fname, mime="application/octet-stream")
306
+ except Exception as e:
307
+ st.error(f"Falha ao criar snapshot: {e}")
308
+ else:
309
+ st.caption("ℹ️ Snapshot físico disponível apenas para SQLite.")
310
+
311
+ st.divider()
312
+ st.subheader("⬆️ Importar dados")
313
+
314
+ mode = st.radio("Modo de importação:", ["APPEND (adicionar)", "REPLACE (substituir tudo)"], horizontal=True)
315
+ mode_val = "append" if "APPEND" in mode else "replace"
316
+
317
+ up_col1, up_col2 = st.columns(2)
318
+ with up_col1:
319
+ zip_file = st.file_uploader("Upload ZIP (CSV por tabela)", type=["zip"])
320
+ if zip_file is not None and st.button("Importar do ZIP", type="primary"):
321
+ try:
322
+ report = import_zip(engine, zip_file.read(), mode=mode_val)
323
+ st.success(f"Import ZIP concluído ({mode_val}).")
324
+ st.json(report)
325
+ registrar_log(usuario=st.session_state.get("usuario"),
326
+ acao=f"Import ZIP ({mode_val}, ambiente={ambiente})",
327
+ tabela="restore", registro_id=None)
328
+ except Exception as e:
329
+ st.error(f"Falha ao importar ZIP: {e}")
330
+
331
+ with up_col2:
332
+ xls_file = st.file_uploader("Upload Excel (.xlsx)", type=["xlsx"])
333
+ if xls_file is not None and st.button("Importar do Excel", type="primary"):
334
+ try:
335
+ report = import_excel(engine, xls_file.read(), mode=mode_val)
336
+ st.success(f"Import Excel concluído ({mode_val}).")
337
+ st.json(report)
338
+ registrar_log(usuario=st.session_state.get("usuario"),
339
+ acao=f"Import XLSX ({mode_val}, ambiente={ambiente})",
340
+ tabela="restore", registro_id=None)
341
+ except Exception as e:
342
+ st.error(f"Falha ao importar Excel: {e}")
343
+
344
+ st.divider()
345
+ st.info("⚠️ Recomendações:\n"
346
+ "• Para restore completo com integridade referencial, prefira snapshot físico no SQLite, ou migrações controladas em bancos como Postgres/SQL Server.\n"
347
+ "• O modo REPLACE desabilita FK temporariamente no SQLite para permitir limpeza; use com cautela.\n"
348
+ "• Em produção, considere gerar backups com versionamento e retenção (ex.: timestamp no nome do arquivo).")
349
+
350
+
351
+ def render():
352
+ # compatível com seu roteador/menu
353
+ main()
354
+
355
+
356
+ if __name__ == "__main__":
357
+ st.set_page_config(page_title="Backup & Restore | ARM", layout="wide")
358
+ main()
359
+
db_monitor.py ADDED
@@ -0,0 +1,278 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # db_monitor.py
3
+ import streamlit as st
4
+ import os
5
+ import shutil
6
+ import time
7
+ from datetime import datetime, timedelta
8
+ from sqlalchemy import text
9
+ # ✅ Use sempre o engine do BANCO ATIVO (em vez de um engine fixo)
10
+ from banco import get_engine, SessionLocal, db_info
11
+ from utils_permissoes import verificar_permissao
12
+ from utils_auditoria import registrar_log
13
+
14
+ # ===============================
15
+ # MONITOR & BACKUP DO BANCO
16
+ # ===============================
17
+ # Objetivo:
18
+ # - Mostrar estatísticas do banco (tamanho, páginas, espaço em disco)
19
+ # - Definir limiar/capacidade alvo e exibir ocupação (%)
20
+ # - Planejar backup (frequência em dias) e retenção (N arquivos)
21
+ # - Executar backup e limpar antigos com confirmação
22
+ # - Acesso restrito por perfil admin
23
+ #
24
+ # Observações:
25
+ # - Em SQLite: usa PRAGMA page_count/page_size + arquivo .db
26
+ # - Em outros dialetos: exibe dialeto e recomenda backup externo
27
+ # - Pasta padrão de backup: ./backups (pode alterar na UI)
28
+ # - Auditoria: registrar_log(usuario, acao="BACKUP/CLEAN/MONITOR", tabela="schema")
29
+
30
+ # (Opcional) rótulo amigável do ambiente atual (Produção/Teste/Treinamento)
31
+ try:
32
+ from db_router import current_db_choice, bank_label
33
+ _HAS_ROUTER = True
34
+ except Exception:
35
+ _HAS_ROUTER = False
36
+ def current_db_choice() -> str:
37
+ return "prod"
38
+ def bank_label(choice: str) -> str:
39
+ return {"prod": "Banco 1 (Produção)", "test": "Banco 2 (Teste)", "treinamento": "Banco 3 (Treinamento)"}\
40
+ .get(choice, choice)
41
+
42
+ # -------------------------
43
+ # Auxiliares de dialeto
44
+ # -------------------------
45
+ def _engine():
46
+ """Retorna o engine do banco ATIVO (de acordo com a escolha no login)."""
47
+ return get_engine()
48
+
49
+ def _dialeto():
50
+ try:
51
+ return _engine().url.get_backend_name()
52
+ except Exception:
53
+ return "desconhecido"
54
+
55
+ def _sqlite_version():
56
+ if _dialeto() != "sqlite":
57
+ return None
58
+ try:
59
+ with _engine().begin() as conn:
60
+ return conn.execute(text("select sqlite_version()")).scalar()
61
+ except Exception:
62
+ return None
63
+
64
+ # -------------------------
65
+ # Info do banco
66
+ # -------------------------
67
+ def _db_file_path():
68
+ # Para SQLite, engine.url.database aponta para o arquivo .db
69
+ try:
70
+ eng = _engine()
71
+ return eng.url.database if eng.url.get_backend_name() == "sqlite" else None
72
+ except Exception:
73
+ return None
74
+
75
+ def _sqlite_stats():
76
+ # Retorna dict com stats do SQLite
77
+ db_path = _db_file_path()
78
+ if not db_path or not os.path.exists(db_path):
79
+ return None
80
+
81
+ size_bytes = os.path.getsize(db_path)
82
+ dir_path = os.path.dirname(os.path.abspath(db_path)) or "."
83
+ total, used, free = shutil.disk_usage(dir_path)
84
+
85
+ with _engine().begin() as conn:
86
+ page_count = conn.execute(text("PRAGMA page_count")).scalar()
87
+ page_size = conn.execute(text("PRAGMA page_size")).scalar()
88
+
89
+ return {
90
+ "db_path": db_path,
91
+ "size_bytes": size_bytes,
92
+ "page_count": page_count,
93
+ "page_size": page_size,
94
+ "calc_bytes": (page_count or 0) * (page_size or 0),
95
+ "disk_total": total,
96
+ "disk_free": free,
97
+ "disk_used": used,
98
+ "sqlite_version": _sqlite_version(),
99
+ }
100
+
101
+ # -------------------------
102
+ # Backup
103
+ # -------------------------
104
+ def _ensure_dir(path: str):
105
+ os.makedirs(path, exist_ok=True)
106
+
107
+ def _fmt_bytes(b: int) -> str:
108
+ # Formata bytes em unidades legíveis
109
+ for unit in ["B","KB","MB","GB","TB"]:
110
+ if b < 1024.0:
111
+ return f"{b:,.2f} {unit}".replace(",", ".")
112
+ b /= 1024.0
113
+ return f"{b:,.2f} PB".replace(",", ".")
114
+
115
+ def _listar_backups(backup_dir: str, base_name: str):
116
+ """Lista backups para o banco atual. Formato: base_name-YYYYMMDD-HHMMSS.db (ou .zip futuramente)"""
117
+ if not os.path.isdir(backup_dir):
118
+ return []
119
+ files = []
120
+ for f in os.listdir(backup_dir):
121
+ if f.startswith(base_name + "-") and f.endswith(".db"):
122
+ full = os.path.join(backup_dir, f)
123
+ files.append((f, full, os.path.getmtime(full)))
124
+ return sorted(files, key=lambda x: x[2], reverse=True) # ordem decrescente
125
+
126
+ def _executar_backup(backup_dir: str):
127
+ """Copia o .db para backups/ com timestamp. Registra auditoria."""
128
+ db_path = _db_file_path()
129
+ if not db_path or not os.path.exists(db_path):
130
+ st.error("Arquivo de banco SQLite não encontrado.")
131
+ return False
132
+
133
+ _ensure_dir(backup_dir)
134
+ base_name = os.path.splitext(os.path.basename(db_path))[0]
135
+ stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
136
+ dest = os.path.join(backup_dir, f"{base_name}-{stamp}.db")
137
+
138
+ try:
139
+ shutil.copyfile(db_path, dest)
140
+ registrar_log(st.session_state.get("usuario"), f"BACKUP criado: {os.path.basename(dest)}", "schema", None)
141
+ st.success(f"✅ Backup criado: {dest}")
142
+ return True
143
+ except Exception as e:
144
+ st.error(f"Erro ao criar backup: {e}")
145
+ return False
146
+
147
+ def _limpar_antigos(backup_dir: str, base_name: str, manter: int):
148
+ """Remove backups antigos, mantendo N mais recentes. Registra auditoria."""
149
+ lst = _listar_backups(backup_dir, base_name)
150
+ if len(lst) <= manter:
151
+ st.info("Nada para remover: já dentro da retenção.")
152
+ return 0
153
+ remover = lst[manter:]
154
+ count = 0
155
+ for _, full, _ in remover:
156
+ try:
157
+ os.remove(full)
158
+ count += 1
159
+ except Exception as e:
160
+ st.error(f"Erro ao remover {full}: {e}")
161
+ if count > 0:
162
+ registrar_log(st.session_state.get("usuario"), f"CLEAN backups antigos: {count} removidos (retain={manter})", "schema", None)
163
+ st.success(f"🧹 {count} backup(s) antigo(s) removido(s).")
164
+ return count
165
+
166
+ # -------------------------
167
+ # UI principal
168
+ # -------------------------
169
+ def main():
170
+ st.title("🗄️ Monitor e Backup do Banco")
171
+
172
+ # 🔐 Proteção: apenas admin
173
+ if st.session_state.get("perfil") != "admin":
174
+ st.error("⛔ Acesso restrito ao administrador.")
175
+ return
176
+
177
+ # Badge/URL do banco ativo (opcional)
178
+ try:
179
+ amb = current_db_choice()
180
+ st.caption(f"🧭 Ambiente: {bank_label(amb)}")
181
+ except Exception:
182
+ pass
183
+ try:
184
+ info = db_info()
185
+ st.caption(f"🔗 Engine URL: {info.get('url')}")
186
+ except Exception:
187
+ pass
188
+
189
+ dial = _dialeto()
190
+ st.caption(f"Dialeto do banco: **{dial}**")
191
+
192
+ # Estatísticas
193
+ stats = _sqlite_stats() if dial == "sqlite" else None
194
+
195
+ # Se não for SQLite, exibe recomendações
196
+ if dial != "sqlite":
197
+ st.info("Este monitor está otimizado para SQLite. Para PostgreSQL/MySQL, configure backup via ferramenta da plataforma (pg_dump/mysqldump) e agendamento externo.")
198
+ st.stop()
199
+
200
+ if not stats:
201
+ st.error("Banco SQLite não encontrado ou inacessível. Verifique o arquivo do banco ativo.")
202
+ return
203
+
204
+ # Painel de estatísticas
205
+ st.subheader("📊 Estatísticas")
206
+ colA, colB, colC = st.columns(3)
207
+ with colA:
208
+ st.metric("Arquivo", os.path.basename(stats["db_path"]))
209
+ st.metric("Tamanho do banco (arquivo)", _fmt_bytes(stats["size_bytes"]))
210
+ with colB:
211
+ st.metric("Páginas (PRAGMA)", f'{stats["page_count"]} × {stats["page_size"]} B')
212
+ st.metric("Cálculo (page_count×page_size)", _fmt_bytes(stats["calc_bytes"]))
213
+ with colC:
214
+ st.metric("Espaço livre no disco", _fmt_bytes(stats["disk_free"]))
215
+ st.metric("SQLite version", stats["sqlite_version"] or "—")
216
+
217
+ st.divider()
218
+
219
+ # Capacidade alvo e ocupação
220
+ st.subheader("🎯 Capacidade & Ocupação")
221
+ capacidade_gb = st.number_input("Capacidade alvo (GB) — alerta quando ultrapassar", min_value=0.1, value=1.0, step=0.1)
222
+ ocupacao_perc = min(100.0, (stats["size_bytes"] / (capacidade_gb * 1024**3)) * 100.0) if capacidade_gb > 0 else 0.0
223
+
224
+ st.progress(min(1.0, ocupacao_perc / 100.0))
225
+ st.caption(f"Ocupação estimada: **{ocupacao_perc:,.2f}%** de {capacidade_gb} GB")
226
+
227
+ if ocupacao_perc >= 80.0:
228
+ st.warning("⚠️ Ocupação acima de 80%. Considere backup/arquivamento.")
229
+
230
+ st.divider()
231
+
232
+ # Planejamento de backup
233
+ st.subheader("🗓️ Planejamento de Backup")
234
+ backup_dir = st.text_input("Pasta de backups", value="backups")
235
+ _ensure_dir(backup_dir) # garante a pasta
236
+ base_name = os.path.splitext(os.path.basename(stats["db_path"]))[0]
237
+ backups = _listar_backups(backup_dir, base_name)
238
+
239
+ # Último e próximo
240
+ ultimo_backup_dt = datetime.fromtimestamp(backups[0][2]) if backups else None
241
+ freq_dias = st.number_input("Frequência (dias)", min_value=1, value=7)
242
+ retencao = st.number_input("Retenção máx. de backups (arquivos)", min_value=1, value=10)
243
+ proximo_backup_dt = (ultimo_backup_dt + timedelta(days=freq_dias)) if ultimo_backup_dt else (datetime.now() + timedelta(days=freq_dias))
244
+
245
+ col1, col2, col3 = st.columns(3)
246
+ with col1:
247
+ st.metric("Último backup", ultimo_backup_dt.strftime("%d/%m/%Y %H:%M:%S") if ultimo_backup_dt else "—")
248
+ with col2:
249
+ st.metric("Próximo previsto", proximo_backup_dt.strftime("%d/%m/%Y %H:%M:%S"))
250
+ with col3:
251
+ st.metric("Backups atuais", len(backups))
252
+
253
+ # Aviso se vencido
254
+ if ultimo_backup_dt and datetime.now() >= proximo_backup_dt:
255
+ st.warning("⏰ Backup previsto já venceu. Execute agora para manter o plano.")
256
+
257
+ # Ações
258
+ st.subheader("⚙️ Ações")
259
+ colX, colY, colZ = st.columns(3)
260
+ with colX:
261
+ if st.button("💾 Backup agora"):
262
+ if _executar_backup(backup_dir):
263
+ st.rerun()
264
+ with colY:
265
+ if st.button("🧹 Limpar antigos (manter retenção)"):
266
+ _limpar_antigos(backup_dir, base_name, int(retencao))
267
+ st.rerun()
268
+ with colZ:
269
+ # Apenas mostra lista dos últimos backups
270
+ if backups:
271
+ st.write("Últimos backups:")
272
+ for f, full, mtime in backups[:5]:
273
+ dt = datetime.fromtimestamp(mtime).strftime("%d/%m/%Y %H:%M:%S")
274
+ st.caption(f"• {f} ({dt})")
275
+
276
+ # Auditoria de visualização (opcional)
277
+ registrar_log(st.session_state.get("usuario"), "MONITOR DB", "schema", None)
278
+
db_router.py ADDED
@@ -0,0 +1,152 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ db_router.py — Roteia Engine/SessionLocal para 'prod' (Load.db), 'test' (Load_teste.db)
5
+ e 'treinamento' (Load_treinamento.db), conforme escolha do usuário (mantida em st.session_state).
6
+
7
+ • set_db_choice("prod"|"test"|"treinamento") → define o banco ativo para a sessão do usuário
8
+ • current_db_choice() → retorna 'prod' | 'test' | 'treinamento'
9
+ • get_engine() → engine do banco ativo (SQLite)
10
+ • get_session_factory() → sessionmaker do banco ativo
11
+ • SessionLocal() → sessão pronta no banco ativo
12
+
13
+ Observação:
14
+ - Este arquivo monta os caminhos dos bancos SQLite com base na pasta do projeto
15
+ (onde está este arquivo), sem depender de .env.
16
+ - Se quiser usar .env futuramente, há uma seção comentada para isso.
17
+ """
18
+
19
+ import os
20
+ import streamlit as st
21
+ from sqlalchemy import create_engine
22
+ from sqlalchemy.orm import sessionmaker
23
+
24
+ # ============================
25
+ # Caminhos dos bancos (SQLite)
26
+ # ============================
27
+ # Pasta base do projeto (onde está este db_router.py)
28
+ BASE_DIR = os.path.dirname(os.path.abspath(__file__))
29
+
30
+ # Nomes dos arquivos de banco conforme sua especificação
31
+ PROD_DB_NAME = "Load.db"
32
+ TEST_DB_NAME = "Load_teste.db"
33
+ TREINAMENTO_DB_NAME = "Load_treinamento.db"
34
+
35
+ # Monta URLs SQLite (formato: sqlite:///C:/.../arquivo.db)
36
+ DB1_PROD_URL = f"sqlite:///{os.path.join(BASE_DIR, PROD_DB_NAME)}"
37
+ DB2_TEST_URL = f"sqlite:///{os.path.join(BASE_DIR, TEST_DB_NAME)}"
38
+ DB3_TREINAMENTO_URL = f"sqlite:///{os.path.join(BASE_DIR, TREINAMENTO_DB_NAME)}"
39
+
40
+ # ============================
41
+ # (Opcional) Uso de .env
42
+ # ============================
43
+ # Se preferir usar .env futuramente, descomente abaixo e defina:
44
+ # DB1_PROD_URL=sqlite:///C:/.../Load.db
45
+ # DB2_TEST_URL=sqlite:///C:/.../Load_teste.db
46
+ # DB3_TREINAMENTO_URL=sqlite:///C:/.../Load_treinamento.db
47
+ #
48
+ # from dotenv import load_dotenv
49
+ # load_dotenv()
50
+ # DB1_PROD_URL = os.getenv("DB1_PROD_URL", DB1_PROD_URL)
51
+ # DB2_TEST_URL = os.getenv("DB2_TEST_URL", DB2_TEST_URL)
52
+ # DB3_TREINAMENTO_URL = os.getenv("DB3_TREINAMENTO_URL", DB3_TREINAMENTO_URL)
53
+
54
+ # ============================
55
+ # Catálogo (helpers de UI — opcional)
56
+ # ============================
57
+ DB_URLS = {
58
+ "prod": DB1_PROD_URL,
59
+ "test": DB2_TEST_URL,
60
+ "treinamento": DB3_TREINAMENTO_URL,
61
+ }
62
+
63
+ DB_LABELS = {
64
+ "prod": "Banco 1 (Produção)",
65
+ "test": "Banco 2 (Teste)",
66
+ "treinamento": "Banco 3 (Treinamento)",
67
+ }
68
+
69
+ def list_banks() -> list[str]:
70
+ """Lista as chaves de bancos disponíveis (opcional para UI)."""
71
+ return list(DB_URLS.keys())
72
+
73
+ def bank_label(choice: str) -> str:
74
+ """Rótulo amigável para a UI (opcional)."""
75
+ return DB_LABELS.get(choice, choice)
76
+
77
+ # ============================
78
+ # Chaves de sessão
79
+ # ============================
80
+ SESSION_DB_CHOICE_KEY = "__db_choice__" # "prod" | "test" | "treinamento"
81
+ SESSION_DB_ENGINE_KEY = "__db_engine__" # cache de engine por escolha
82
+ SESSION_DB_FACTORY_KEY = "__db_session_factory__" # cache de sessionmaker por escolha
83
+
84
+ def set_db_choice(choice: str):
85
+ """
86
+ Define o banco ativo para a sessão do usuário.
87
+ choice ∈ {"prod", "test", "treinamento"}.
88
+ """
89
+ choice = (choice or "").strip().lower()
90
+ if choice not in DB_URLS:
91
+ raise ValueError(f"db_choice inválido. Use uma destas chaves: {list(DB_URLS.keys())}")
92
+ st.session_state[SESSION_DB_CHOICE_KEY] = choice
93
+ # Ao trocar banco, invalida caches locais
94
+ st.session_state.pop(SESSION_DB_ENGINE_KEY, None)
95
+ st.session_state.pop(SESSION_DB_FACTORY_KEY, None)
96
+
97
+ def current_db_choice() -> str:
98
+ """Retorna 'prod' | 'test' | 'treinamento'. Default: 'prod'."""
99
+ return st.session_state.get(SESSION_DB_CHOICE_KEY, "prod")
100
+
101
+ def _url_for_choice(choice: str) -> str:
102
+ return DB_URLS[choice]
103
+
104
+ def get_engine():
105
+ """
106
+ Entrega o engine do banco ATIVO (por sessão). Cria se não existir.
107
+ """
108
+ choice = current_db_choice()
109
+ cached = st.session_state.get(SESSION_DB_ENGINE_KEY)
110
+ if cached and getattr(cached, "__db_choice__", None) == choice:
111
+ return cached
112
+
113
+ url = _url_for_choice(choice)
114
+ engine_args = {
115
+ "echo": False,
116
+ "pool_pre_ping": True,
117
+ # check_same_thread=False para permitir uso em múltiplas threads do Streamlit
118
+ "connect_args": {"check_same_thread": False} if url.startswith("sqlite") else {},
119
+ }
120
+
121
+ eng = create_engine(url, **engine_args)
122
+ setattr(eng, "__db_choice__", choice)
123
+ st.session_state[SESSION_DB_ENGINE_KEY] = eng
124
+ return eng
125
+
126
+ def get_session_factory():
127
+ """
128
+ Entrega um sessionmaker vinculado ao engine do banco ATIVO.
129
+ """
130
+ choice = current_db_choice()
131
+ fac = st.session_state.get(SESSION_DB_FACTORY_KEY)
132
+ if fac and getattr(fac, "__db_choice__", None) == choice:
133
+ return fac
134
+
135
+ fac = sessionmaker(bind=get_engine(), autocommit=False, autoflush=False)
136
+ setattr(fac, "__db_choice__", choice)
137
+ st.session_state[SESSION_DB_FACTORY_KEY] = fac
138
+ return fac
139
+
140
+ def SessionLocal():
141
+ """
142
+ Cria uma sessão (sempre no banco ativo).
143
+ Uso:
144
+ db = SessionLocal()
145
+ try:
146
+ ...
147
+ finally:
148
+ db.close()
149
+ """
150
+ return get_session_factory()()
151
+
152
+
db_tools.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # db_tools.py
3
+ import streamlit as st
4
+ from banco import engine
5
+ from sqlalchemy import text
6
+
7
+ # Ajuste a sintaxe conforme seu SGDB. Abaixo está para bancos comuns (Postgres, SQLite, MySQL*).
8
+ # *MySQL funciona, mas o CREATE UNIQUE INDEX IF NOT EXISTS pode variar por versão. Ver obs. abaixo.
9
+ SQLS = [
10
+ "ALTER TABLE usuarios ADD COLUMN nome VARCHAR(255);",
11
+ "ALTER TABLE usuarios ADD COLUMN email VARCHAR(255);",
12
+ "CREATE UNIQUE INDEX IF NOT EXISTS ix_usuarios_email ON usuarios (email);"
13
+ ]
14
+
15
+ def aplicar_alteracoes():
16
+ with engine.begin() as conn:
17
+ for sql in SQLS:
18
+ conn.execute(text(sql))
19
+
20
+ def verificar_colunas():
21
+ # Tenta listar colunas da tabela usuarios (funciona na maioria dos drivers)
22
+ try:
23
+ with engine.begin() as conn:
24
+ result = conn.execute(text("PRAGMA table_info(usuarios)")) # SQLite
25
+ cols = [row[1] for row in result.fetchall()]
26
+ return cols
27
+ except:
28
+ # Fallback genérico usando INFORMATION_SCHEMA (Postgres/MySQL)
29
+ try:
30
+ with engine.begin() as conn:
31
+ result = conn.execute(text("""
32
+ SELECT column_name
33
+ FROM information_schema.columns
34
+ WHERE table_name = 'usuarios'
35
+ ORDER BY ordinal_position;
36
+ """))
37
+ cols = [row[0] for row in result.fetchall()]
38
+ return cols
39
+ except:
40
+ return None
41
+
42
+ def main():
43
+ st.title("🛠️ Ferramentas de Banco")
44
+
45
+ if st.session_state.get("perfil") != "admin":
46
+ st.error("❌ Acesso restrito ao administrador.")
47
+ return
48
+
49
+ st.info("Este utilitário adiciona as colunas **nome** e **email** na tabela **usuarios** e cria o índice único do **email**.")
50
+
51
+ cols = verificar_colunas()
52
+ if cols:
53
+ st.write("📋 Colunas atuais em `usuarios`:", ", ".join(cols))
54
+ else:
55
+ st.warning("Não foi possível listar colunas automaticamente. Ainda é possível aplicar as alterações.")
56
+
57
+ if st.button("✅ Aplicar alterações (nome/email + índice)"):
58
+ try:
59
+ aplicar_alteracoes()
60
+ st.success("Alterações aplicadas com sucesso! Reinicie a aplicação se necessário.")
61
+ except Exception as e:
62
+ st.error(f"Erro ao aplicar alterações: {e}")
63
+ st.stop()
64
+
65
+ st.caption("Dica: após aplicar, confira o cadastro/edição de usuários e o login para ver email/nome funcionando.")
env_audit.py ADDED
@@ -0,0 +1,321 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ env_audit.py — Auditoria de ambiente (TESTE/Produção) para projetos Streamlit
5
+ Autor: Rodrigo / ARM | 2026
6
+
7
+ Verifica:
8
+ • Uso de st.set_page_config fora de if __name__ == "__main__"
9
+ • Presença de funções main()/render() em módulos
10
+ • Diferenças entre arquivos .py e modules_map.py (sugestão de "file")
11
+ • Heurística de banco (banco.py apontando para produção)
12
+ • Gera env_audit_report.json + imprime resumo Markdown
13
+
14
+ Uso:
15
+ python env_audit.py --env "C:\\...\\ambiente_teste\\LoadApp"
16
+ (ou) comparar pastas:
17
+ python env_audit.py --prod "C:\\...\\producao\\LoadApp" --test "C:\\...\\ambiente_teste\\LoadApp"
18
+ """
19
+
20
+ import os, sys, json, ast, re
21
+ from typing import Dict, List, Tuple, Optional
22
+
23
+ def list_py(base: str) -> List[str]:
24
+ files = []
25
+ for root, _, names in os.walk(base):
26
+ for n in names:
27
+ if n.endswith(".py"):
28
+ files.append(os.path.normpath(os.path.join(root, n)))
29
+ return files
30
+
31
+ def relpath_set(base: str) -> set:
32
+ return set([os.path.relpath(p, base) for p in list_py(base)])
33
+
34
+ def parse_ast(path: str) -> Optional[ast.AST]:
35
+ try:
36
+ with open(path, "r", encoding="utf-8") as f:
37
+ src = f.read()
38
+ return ast.parse(src, filename=path)
39
+ except Exception:
40
+ return None
41
+
42
+ def has_set_page_config_outside_main(tree: ast.AST) -> bool:
43
+ """
44
+ Retorna True se encontrar chamada a set_page_config fora do guard if __name__ == "__main__".
45
+ Heurística: procura ast.Call para nome/atributo 'set_page_config' e confere se está dentro de um If guard.
46
+ """
47
+ if tree is None:
48
+ return False
49
+
50
+ calls = []
51
+ parents = {}
52
+
53
+ class ParentAnnotator(ast.NodeVisitor):
54
+ def generic_visit(self, node):
55
+ for child in ast.iter_child_nodes(node):
56
+ parents[child] = node
57
+ super().generic_visit(node)
58
+
59
+ class CallCollector(ast.NodeVisitor):
60
+ def visit_Call(self, node):
61
+ # detecta set_page_config
62
+ name = None
63
+ if isinstance(node.func, ast.Name):
64
+ name = node.func.id
65
+ elif isinstance(node.func, ast.Attribute):
66
+ name = node.func.attr
67
+ if name == "set_page_config":
68
+ calls.append(node)
69
+ self.generic_visit(node)
70
+
71
+ ParentAnnotator().visit(tree)
72
+ CallCollector().visit(tree)
73
+
74
+ def inside_main_guard(node: ast.AST) -> bool:
75
+ # sobe na árvore: se estiver dentro de um If com __name__ == "__main__"
76
+ cur = node
77
+ while cur in parents:
78
+ cur = parents[cur]
79
+ if isinstance(cur, ast.If):
80
+ # test é __name__ == "__main__"
81
+ t = cur.test
82
+ # match Name('__name__') == Constant('__main__')
83
+ if isinstance(t, ast.Compare):
84
+ left = t.left
85
+ comparators = t.comparators
86
+ if isinstance(left, ast.Name) and left.id == "__name__" and comparators:
87
+ comp = comparators[0]
88
+ if isinstance(comp, ast.Constant) and comp.value == "__main__":
89
+ return True
90
+ return False
91
+
92
+ # Se houver set_page_config e nenhuma estiver dentro do guard, marca True (incorreto)
93
+ for c in calls:
94
+ if not inside_main_guard(c):
95
+ return True
96
+ return False
97
+
98
+ def has_function(tree: ast.AST, fn_name: str) -> bool:
99
+ if tree is None:
100
+ return False
101
+ for node in ast.walk(tree):
102
+ if isinstance(node, ast.FunctionDef) and node.name == fn_name:
103
+ return True
104
+ return False
105
+
106
+ def guess_module_name(file_path: str, base: str) -> str:
107
+ """Retorna o nome do módulo sem extensão e sem subdiretórios ('modulos/x.py' -> 'x')."""
108
+ rel = os.path.relpath(file_path, base)
109
+ name = os.path.splitext(os.path.basename(rel))[0]
110
+ return name
111
+
112
+ def read_modules_map(path: str) -> Dict[str, Dict]:
113
+ """
114
+ Lê modules_map.py e tenta extrair o dict MODULES.
115
+ Método simples: regex para entries de primeiro nível.
116
+ """
117
+ modmap = {}
118
+ mm_path = os.path.join(path, "modules_map.py")
119
+ if not os.path.isfile(mm_path):
120
+ return modmap
121
+ try:
122
+ with open(mm_path, "r", encoding="utf-8") as f:
123
+ src = f.read()
124
+ # encontra blocos de entries "key": { ... }
125
+ # Simplificação: captura 'MODULES = { ... }' então entradas por aspas + chave.
126
+ main_match = re.search(r"MODULES\s*=\s*\{(.+?)\}\s*$", src, flags=re.S)
127
+ if not main_match:
128
+ return modmap
129
+ body = main_match.group(1)
130
+ # Captura nomes de entradas: "nome": { ... }
131
+ entry_re = re.compile(r'"\'["\']\s*:\s*\{(.*?)\}', re.S)
132
+ for em in entry_re.finditer(body):
133
+ key = em.group(1)
134
+ obj = em.group(2)
135
+ # captura "file": "xyz" se houver
136
+ file_m = re.search(r'["\']file["\']\s*:\s*"\'["\']', obj)
137
+ label_m = re.search(r'["\']label["\']\s*:\s*"\'["\']', obj)
138
+ perfis_m = re.findall(r'["\']perfis["\']\s*:\s*\[([^\]]+)\]', obj)
139
+ grupo_m = re.search(r'["\']grupo["\']\s*:\s*"\'["\']', obj)
140
+ modmap[key] = {
141
+ "file": file_m.group(1) if file_m else None,
142
+ "label": label_m.group(1) if label_m else key,
143
+ "perfis": perfis_m[0] if perfis_m else None,
144
+ "grupo": grupo_m.group(1) if grupo_m else None,
145
+ }
146
+ except Exception:
147
+ pass
148
+ return modmap
149
+
150
+ def audit_env(env_path: str, prod_path: Optional[str] = None) -> Dict:
151
+ report = {
152
+ "env_path": env_path,
153
+ "prod_path": prod_path,
154
+ "files_total": 0,
155
+ "issues": {
156
+ "set_page_config_outside_main": [], # lista de arquivos
157
+ "missing_entry_points": [], # (arquivo, has_main, has_render)
158
+ "modules_map_mismatches": [], # (key, file, suggestion)
159
+ "db_prod_risk": [], # banco.py hints
160
+ "missing_in_test": [], # se prod_path fornecido
161
+ "extra_in_test": [], # se prod_path fornecido
162
+ },
163
+ "summary": {}
164
+ }
165
+
166
+ env_files = list_py(env_path)
167
+ report["files_total"] = len(env_files)
168
+
169
+ # modules_map
170
+ modmap = read_modules_map(env_path)
171
+
172
+ # varredura de .py
173
+ for f in env_files:
174
+ tree = parse_ast(f)
175
+ if has_set_page_config_outside_main(tree):
176
+ report["issues"]["set_page_config_outside_main"].append(os.path.relpath(f, env_path))
177
+
178
+ has_main = has_function(tree, "main")
179
+ has_render = has_function(tree, "render")
180
+ if not (has_main or has_render):
181
+ report["issues"]["missing_entry_points"].append((os.path.relpath(f, env_path), has_main, has_render))
182
+
183
+ # banco.py heuristic
184
+ banco_path = os.path.join(env_path, "banco.py")
185
+ if os.path.isfile(banco_path):
186
+ try:
187
+ with open(banco_path, "r", encoding="utf-8") as bf:
188
+ bsrc = bf.read().lower()
189
+ # heurísticas simples (ajuste conforme seu cenário)
190
+ hints = []
191
+ if "prod" in bsrc or "production" in bsrc:
192
+ hints.append("contém 'prod'/'production' no banco.py (pode estar apontando para produção)")
193
+ if "localhost" in bsrc or "127.0.0.1" in bsrc:
194
+ hints.append("contém localhost (ok se seu DB de teste for local)")
195
+ if "ambiente_teste" in bsrc or "test" in bsrc:
196
+ hints.append("contém 'test'/'ambiente_teste' (bom indício de DB de teste)")
197
+ if hints:
198
+ report["issues"]["db_prod_risk"].extend(hints)
199
+ except Exception:
200
+ pass
201
+
202
+ # Mismatch entre modules_map e arquivos
203
+ # Constrói um conjunto de nomes de módulo possíveis (arquivo base sem .py)
204
+ module_name_set = set([guess_module_name(f, env_path) for f in env_files])
205
+ for key, info in modmap.items():
206
+ file_hint = info.get("file")
207
+ target = file_hint or key
208
+ if target not in module_name_set:
209
+ # sugere arquivo com nome mais próximo
210
+ suggestions = [m for m in module_name_set if m.lower() == key.lower()]
211
+ sug = suggestions[0] if suggestions else None
212
+ report["issues"]["modules_map_mismatches"].append((key, file_hint, sug))
213
+
214
+ # Diferenças entre PRODUÇÃO e TESTE (se prod_path fornecido)
215
+ if prod_path and os.path.isdir(prod_path):
216
+ prod_set = relpath_set(prod_path)
217
+ test_set = relpath_set(env_path)
218
+ missing = sorted(list(prod_set - test_set))
219
+ extra = sorted(list(test_set - prod_set))
220
+ report["issues"]["missing_in_test"] = missing
221
+ report["issues"]["extra_in_test"] = extra
222
+
223
+ # resumo
224
+ report["summary"] = {
225
+ "files_total": report["files_total"],
226
+ "set_page_config_outside_main_count": len(report["issues"]["set_page_config_outside_main"]),
227
+ "missing_entry_points_count": len(report["issues"]["missing_entry_points"]),
228
+ "modules_map_mismatches_count": len(report["issues"]["modules_map_mismatches"]),
229
+ "db_risk_flags_count": len(report["issues"]["db_prod_risk"]),
230
+ "missing_in_test_count": len(report["issues"]["missing_in_test"]),
231
+ "extra_in_test_count": len(report["issues"]["extra_in_test"]),
232
+ }
233
+
234
+ return report
235
+
236
+ def print_markdown(report: Dict):
237
+ env_path = report["env_path"]
238
+ prod_path = report.get("prod_path") or "—"
239
+ print(f"# 🧪 Auditoria de Ambiente\n")
240
+ print(f"- **Pasta auditada (TESTE)**: `{env_path}`")
241
+ print(f"- **Pasta de PRODUÇÃO (comparação)**: `{prod_path}`\n")
242
+
243
+ s = report["summary"]
244
+ print("## ✅ Resumo")
245
+ for k, v in s.items():
246
+ print(f"- {k.replace('_',' ').title()}: {v}")
247
+ print()
248
+
249
+ issues = report["issues"]
250
+
251
+ if issues["set_page_config_outside_main"]:
252
+ print("## ⚠️ `st.set_page_config` fora de `if __name__ == '__main__'`")
253
+ for f in issues["set_page_config_outside_main"]:
254
+ print(f"- {f}")
255
+ print("**Ajuste**: mover `st.set_page_config(...)` para dentro de:\n")
256
+ print("```python\nif __name__ == \"__main__\":\n st.set_page_config(...)\n main()\n```\n")
257
+
258
+ if issues["missing_entry_points"]:
259
+ print("## ⚠️ Módulos sem `main()` e sem `render()`")
260
+ for f, has_main, has_render in issues["missing_entry_points"]:
261
+ print(f"- {f} — main={has_main}, render={has_render}")
262
+ print("**Ajuste**: garantir pelo menos uma função de entrada (`main` ou `render`).\n")
263
+
264
+ if issues["modules_map_mismatches"]:
265
+ print("## ⚠️ Diferenças entre `modules_map.py` e arquivos")
266
+ for key, file_hint, sug in issues["modules_map_mismatches"]:
267
+ print(f"- key=`{key}` | file=`{file_hint}` | sugestão de arquivo=`{sug or 'verificar manualmente'}`")
268
+ print("**Ajuste**: em `modules_map.py`, defina `\"file\": \"nome_do_arquivo\"` quando o nome do arquivo não for igual à key.\n")
269
+
270
+ if issues["db_prod_risk"]:
271
+ print("## ⚠️ Banco (heurística)")
272
+ for m in issues["db_prod_risk"]:
273
+ print(f"- {m}")
274
+ print("**Ajuste**: confirmar que `banco.py` do TESTE aponta para DB de teste (não produção).\n")
275
+
276
+ if issues["missing_in_test"]:
277
+ print("## 🟠 Arquivos que existem na PRODUÇÃO e faltam no TESTE")
278
+ for f in issues["missing_in_test"]:
279
+ print(f"- {f}")
280
+ print()
281
+
282
+ if issues["extra_in_test"]:
283
+ print("## 🔵 Arquivos que existem no TESTE e não existem na PRODUÇÃO")
284
+ for f in issues["extra_in_test"]:
285
+ print(f"- {f}")
286
+ print()
287
+
288
+ def main():
289
+ import argparse
290
+ p = argparse.ArgumentParser()
291
+ p.add_argument("--env", help="Pasta do ambiente a auditar (ex.: ...\\ambiente_teste\\LoadApp)")
292
+ p.add_argument("--prod", help="(Opcional) Pasta de PRODUÇÃO para comparação.")
293
+ p.add_argument("--test", help="(Opcional) Pasta de TESTE para comparação (se usar --prod).")
294
+ args = p.parse_args()
295
+
296
+ if args.env:
297
+ env_path = os.path.normpath(args.env)
298
+ report = audit_env(env_path)
299
+ elif args.prod and args.test:
300
+ prod = os.path.normpath(args.prod)
301
+ test = os.path.normpath(args.test)
302
+ report = audit_env(test, prod_path=prod)
303
+ else:
304
+ print("Uso: python env_audit.py --env \"C:\\...\\ambiente_teste\\LoadApp\"\n"
305
+ " ou: python env_audit.py --prod \"C:\\...\\producao\\LoadApp\" --test \"C:\\...\\ambiente_teste\\LoadApp\"")
306
+ sys.exit(2)
307
+
308
+ # salva JSON
309
+ out_json = os.path.join(os.getcwd(), "env_audit_report.json")
310
+ try:
311
+ with open(out_json, "w", encoding="utf-8") as f:
312
+ json.dump(report, f, ensure_ascii=False, indent=2)
313
+ print(f"\n💾 Relatório salvo em: {out_json}\n")
314
+ except Exception as e:
315
+ print(f"Falha ao salvar JSON: {e}")
316
+
317
+ # imprime resumo markdown
318
+ print_markdown(report)
319
+
320
+ if __name__ == "__main__":
321
+ main()
fix_schema.py ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # fix_schema.py
3
+ # -*- coding: utf-8 -*-
4
+ """
5
+ Utilitário rápido para ajustar o schema da tabela 'usuarios' em SQLite:
6
+ - Verifica se a coluna 'data_aniversario' existe.
7
+ - Se não existir, cria com tipo DATE (compatível com datetime.date via SQLAlchemy).
8
+ ⚠️ Execute isso no banco ATIVO (Produção/Teste), conforme seu db_router.
9
+ """
10
+
11
+ from sqlalchemy import text
12
+ from banco import engine # usa o mesmo engine do app
13
+
14
+ def column_exists(conn, table: str, column: str) -> bool:
15
+ # SQLite: PRAGMA para listar colunas
16
+ res = conn.execute(text(f"PRAGMA table_info({table});")).fetchall()
17
+ cols = [row[1] for row in res] # row[1] = nome da coluna
18
+ return column in cols
19
+
20
+ def add_date_column(conn, table: str, column: str):
21
+ # SQLite aceita ALTER TABLE ADD COLUMN simples
22
+ conn.execute(text(f"ALTER TABLE {table} ADD COLUMN {column} DATE;"))
23
+
24
+ def main():
25
+ with engine.begin() as conn:
26
+ if column_exists(conn, "usuarios", "data_aniversario"):
27
+ print("✅ Coluna 'data_aniversario' já existe em 'usuarios'.")
28
+ else:
29
+ print("➕ Criando coluna 'data_aniversario' (DATE) em 'usuarios'...")
30
+ add_date_column(conn, "usuarios", "data_aniversario")
31
+ print("✅ Coluna criada com sucesso.")
32
+
33
+ if __name__ == "__main__":
34
+ main()
form_equipamento.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import streamlit as st
2
+ from utils_fpso import campo_fpso
3
+
4
+ MODAL_LISTA = ["", "AÉREO", "MARÍTIMO", "EXPRESSO"]
5
+ RESP_ERRO_LISTA = ["", "Sim", "Não"]
6
+ INCLUSAO_EXCLUSAO_LISTA = ["", "INCLUSÃO", "EXCLUSÃO"]
7
+
8
+
9
+ def form_equipamento(registro):
10
+ st.markdown("### 📦 Dados do Equipamento")
11
+
12
+ col1, col2, col3 = st.columns(3)
13
+
14
+ # =====================
15
+ # COLUNA 1
16
+ # =====================
17
+ with col1:
18
+ fpso1 = campo_fpso("FPSO1", registro.fpso1)
19
+ fpso = campo_fpso("FPSO", registro.fpso)
20
+ data_coleta = st.date_input("Data Coleta", registro.data_coleta)
21
+ especialista = st.text_input("Especialista", registro.especialista or "")
22
+ conferente = st.text_input("Conferente", registro.conferente or "")
23
+ osm = st.text_input("OSM", registro.osm or "")
24
+
25
+ # =====================
26
+ # COLUNA 2
27
+ # =====================
28
+ with col2:
29
+ modal = st.selectbox(
30
+ "Modal",
31
+ MODAL_LISTA,
32
+ index=MODAL_LISTA.index(registro.modal) if registro.modal in MODAL_LISTA else 0
33
+ )
34
+
35
+ quant_equip = st.number_input(
36
+ "Quantidade Equipamentos",
37
+ min_value=0,
38
+ value=registro.quant_equip or 0
39
+ )
40
+
41
+ mrob = st.text_input("MROB", registro.mrob or "")
42
+ linhas_osm = st.number_input("Linhas OSM", value=registro.linhas_osm or 0)
43
+ linhas_mrob = st.number_input("Linhas MROB", value=registro.linhas_mrob or 0)
44
+ linhas_erros = st.number_input("Linhas com Erro", value=registro.linhas_erros or 0)
45
+
46
+ # =====================
47
+ # COLUNA 3
48
+ # =====================
49
+ with col3:
50
+ erro_storekeeper = st.selectbox(
51
+ "Erro Storekeeper",
52
+ RESP_ERRO_LISTA,
53
+ index=RESP_ERRO_LISTA.index(registro.erro_storekeeper)
54
+ if registro.erro_storekeeper in RESP_ERRO_LISTA else 0
55
+ )
56
+
57
+ erro_operacao = st.selectbox(
58
+ "Erro Operação WH",
59
+ RESP_ERRO_LISTA,
60
+ index=RESP_ERRO_LISTA.index(registro.erro_operacao)
61
+ if registro.erro_operacao in RESP_ERRO_LISTA else 0
62
+ )
63
+
64
+ erro_especialista = st.selectbox(
65
+ "Erro Especialista WH",
66
+ RESP_ERRO_LISTA,
67
+ index=RESP_ERRO_LISTA.index(registro.erro_especialista)
68
+ if registro.erro_especialista in RESP_ERRO_LISTA else 0
69
+ )
70
+
71
+ erro_outros = st.selectbox(
72
+ "Erro Outros",
73
+ RESP_ERRO_LISTA,
74
+ index=RESP_ERRO_LISTA.index(registro.erro_outros)
75
+ if registro.erro_outros in RESP_ERRO_LISTA else 0
76
+ )
77
+
78
+ inclusao_exclusao = st.selectbox(
79
+ "Inclusão / Exclusão",
80
+ INCLUSAO_EXCLUSAO_LISTA,
81
+ index=INCLUSAO_EXCLUSAO_LISTA.index(registro.inclusao_exclusao)
82
+ if registro.inclusao_exclusao in INCLUSAO_EXCLUSAO_LISTA else 0
83
+ )
84
+
85
+ # =====================
86
+ # CAMPOS GERAIS
87
+ # =====================
88
+ st.markdown("### 📝 Informações Complementares")
89
+
90
+ solicitante = st.text_input("Solicitante", registro.solicitante or "")
91
+ ivo = st.text_input("Motivo Inclusão / Exclusão", registro.ivo or "")
92
+ requisitante = st.text_input("Requisitante", registro.requisitante or "")
93
+ nota_fiscal = st.text_input("Nota Fiscal", registro.nota_fiscal or "")
94
+ impacto = st.text_input("Impacto", registro.impacto or "")
95
+ dimensao = st.text_input("Dimensão", registro.dimensao or "")
96
+ observacoes = st.text_area("Observações", registro.observacoes or "", height=120)
97
+
98
+ return locals()
formulario.py ADDED
@@ -0,0 +1,519 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ from datetime import datetime, date
5
+ from banco import SessionLocal
6
+ from models import Equipamento
7
+ from sqlalchemy import distinct
8
+ from log import registrar_log
9
+
10
+ # ======================================================
11
+ # LISTAS FIXAS
12
+ # ======================================================
13
+ MODAL_LISTA = ["", "AÉREO", "MARÍTIMO", "EXPRESSO"]
14
+ INCLUSAO_EXCLUSAO_LISTA = ["", "INCLUSÃO", "EXCLUSÃO"]
15
+ RESP_ERRO_LISTA = ["", "Sim", "Não"]
16
+ D_LISTA = ["", "D1", "D2", "D3"]
17
+
18
+ # ======================================================
19
+ # LAYOUT
20
+ # ======================================================
21
+ def layout_padrao(titulo: str):
22
+ st.title(titulo)
23
+ st.divider()
24
+
25
+ # ======================================================
26
+ # UTILS
27
+ # ======================================================
28
+ def _safe_index(options, value, default=0):
29
+ try:
30
+ return options.index(value)
31
+ except Exception:
32
+ return default
33
+
34
+ def _ss_get(key, default=None):
35
+ return st.session_state.get(key, default)
36
+
37
+ def _ss_set(key, value):
38
+ st.session_state[key] = value
39
+
40
+ # 🟢 Parser de datas robusto (aceita date, datetime e string)
41
+ def _parse_date_any(v):
42
+ if isinstance(v, date):
43
+ return v
44
+ if isinstance(v, datetime):
45
+ return v.date()
46
+ if isinstance(v, str):
47
+ s = v.strip()
48
+ # ISO: YYYY-MM-DD
49
+ try:
50
+ return date.fromisoformat(s)
51
+ except Exception:
52
+ pass
53
+ # BR: DD/MM/YYYY
54
+ try:
55
+ return datetime.strptime(s, "%d/%m/%Y").date()
56
+ except Exception:
57
+ pass
58
+ # YYYY/MM/DD (alguns DBs/ETLs geram assim)
59
+ try:
60
+ return datetime.strptime(s, "%Y/%m/%d").date()
61
+ except Exception:
62
+ pass
63
+ return None
64
+
65
+ def _build_prefill_from_record(r: Equipamento) -> dict:
66
+ # 🟢 Usa parser que converte string->date quando possível
67
+ dc = _parse_date_any(getattr(r, "data_coleta", None))
68
+ return {
69
+ "fpso1": r.fpso1 or "",
70
+ "fpso": r.fpso or "",
71
+ "data_coleta": dc, # 🟢
72
+ "especialista": r.especialista or "",
73
+ "conferente": r.conferente or "",
74
+ "osm": r.osm or "",
75
+ "modal": r.modal or "",
76
+ "quant_equip": int(r.quant_equip or 0),
77
+ "mrob": r.mrob or "",
78
+ "linhas_osm": int(r.linhas_osm or 0),
79
+ "linhas_mrob": int(r.linhas_mrob or 0),
80
+ "linhas_erros": int(r.linhas_erros or 0),
81
+ "erro_storekeeper": r.erro_storekeeper or "",
82
+ "erro_operacao": r.erro_operacao or "",
83
+ "erro_especialista": r.erro_especialista or "",
84
+ "erro_outros": r.erro_outros or "",
85
+ "inclusao_exclusao": r.inclusao_exclusao or "",
86
+ "po": r.po or "",
87
+ "part_number": r.part_number or "",
88
+ "material": r.material or "",
89
+ "solicitante": r.solicitante or "",
90
+ "requisitante": r.requisitante or "",
91
+ "nota_fiscal": r.nota_fiscal or "",
92
+ "impacto": r.impacto or "",
93
+ "dimensao": r.dimensao or "",
94
+ "motivo": getattr(r, "motivo", "") or "",
95
+ "observacoes": r.observacoes or "",
96
+ "dia_inclusao": r.dia_inclusao or "",
97
+ "_origem_id": r.id,
98
+ }
99
+
100
+ @st.cache_data
101
+ def get_distinct(_campo):
102
+ db = SessionLocal()
103
+ try:
104
+ rows = db.query(distinct(_campo)).filter(_campo.isnot(None)).filter(_campo != "").all()
105
+ return sorted([r[0] for r in rows])
106
+ finally:
107
+ db.close()
108
+
109
+ def get_fpsos():
110
+ return [""] + get_distinct(Equipamento.fpso)
111
+
112
+ def get_fpsos1():
113
+ return [""] + get_distinct(Equipamento.fpso1)
114
+
115
+ def get_notas_fiscais():
116
+ return sorted([str(x) for x in get_distinct(Equipamento.nota_fiscal)])
117
+
118
+ def _apply_prefill_to_state(prefill: dict, debug=False):
119
+ """
120
+ Aplica o prefill uma única vez por origem (ID), antes de criar widgets.
121
+ Se o valor não existir nas listas, move para o campo '➕ Novo ...' + text_input.
122
+ """
123
+ if not prefill:
124
+ if debug: st.info("DEBUG: prefill vazio. Nada a aplicar.")
125
+ return
126
+
127
+ origem = prefill.get("_origem_id")
128
+ if _ss_get("__prefill_applied__") == origem:
129
+ if debug: st.info(f"DEBUG: prefill já aplicado para origem {origem}.")
130
+ return
131
+
132
+ # Carrega todos os campos no session_state como w_*
133
+ for k, v in prefill.items():
134
+ _ss_set(f"w_{k}", v)
135
+
136
+ # Backups para os inputs de texto dos "➕ Novo ..."
137
+ _ss_set("w_fpso1_text", prefill.get("fpso1", ""))
138
+ _ss_set("w_fpso_text", prefill.get("fpso", ""))
139
+
140
+ # Normalização para selects fixos
141
+ def _norm(key, allowed):
142
+ v = _ss_get(key, "")
143
+ if v not in allowed:
144
+ _ss_set(key, allowed[0])
145
+
146
+ _norm("w_modal", MODAL_LISTA)
147
+ _norm("w_erro_storekeeper", RESP_ERRO_LISTA)
148
+ _norm("w_erro_operacao", RESP_ERRO_LISTA)
149
+ _norm("w_erro_especialista", RESP_ERRO_LISTA)
150
+ _norm("w_erro_outros", RESP_ERRO_LISTA)
151
+ _norm("w_inclusao_exclusao", INCLUSAO_EXCLUSAO_LISTA)
152
+ _norm("w_dia_inclusao", D_LISTA)
153
+
154
+ # 🟢 Tipos/valores coerentes
155
+ _ss_set("w_quant_equip", int(_ss_get("w_quant_equip", 0) or 0))
156
+ _ss_set("w_linhas_osm", int(_ss_get("w_linhas_osm", 0) or 0))
157
+ _ss_set("w_linhas_mrob", int(_ss_get("w_linhas_mrob", 0) or 0))
158
+ _ss_set("w_linhas_erros", int(_ss_get("w_linhas_erros", 0) or 0))
159
+
160
+ # 🟢 NÃO força “hoje” se vier None — mas o widget precisa de um valor (trata na UI)
161
+ # _ss_set("w_data_coleta", _ss_get("w_data_coleta") or date.today())
162
+
163
+ _ss_set("__prefill_applied__", origem)
164
+ if debug: st.success(f"DEBUG: prefill aplicado. origem={origem}")
165
+
166
+ # ======================================================
167
+ # APP
168
+ # ======================================================
169
+ def main():
170
+ layout_padrao(
171
+ "📋 CONTROLE DE OSM – Inclusões / Exclusões • Visão por linha (D3 a partir das 16h)"
172
+ )
173
+
174
+ debug = st.toggle("🔍 Modo debug", value=False)
175
+
176
+ # ======================================================
177
+ # EXPANDER • Pré-carregar por Nota Fiscal
178
+ # ======================================================
179
+ with st.expander("🧾 Pré-carregar via Nota Fiscal (clonar como nova entrada)", expanded=False):
180
+ col1, col2, col3 = st.columns([2, 2, 1.2])
181
+ nf_digitado = col1.text_input("Digite a Nota Fiscal", key="nf_lookup")
182
+ nf_select = col2.selectbox("Ou selecione", [""] + get_notas_fiscais(), key="nf_select")
183
+ marcar_origem = col3.checkbox("Marcar origem", value=True, key="nf_marcar_origem")
184
+
185
+ c1, c2 = st.columns(2)
186
+ buscar = c1.button("🔎 Buscar NF", key="btn_buscar_nf")
187
+ limpar = c2.button("🧹 Limpar pré-preenchimento", key="btn_limpar_prefill")
188
+
189
+ if limpar:
190
+ # Limpa apenas os w_* e marcadores de prefill/resultados
191
+ for k in list(st.session_state.keys()):
192
+ if k.startswith("w_"):
193
+ del st.session_state[k]
194
+ st.session_state.pop("form_prefill", None)
195
+ st.session_state.pop("__prefill_applied__", None)
196
+ st.session_state.pop("__nf_busca_result__", None)
197
+ st.session_state.pop("__nf_busca_opts__", None)
198
+ st.session_state.pop("sel_registro_base", None)
199
+ st.success("Pré-preenchimento limpo.")
200
+ st.rerun()
201
+
202
+ if buscar:
203
+ nf = (nf_digitado or nf_select or "").strip()
204
+ if not nf:
205
+ st.warning("Informe ou selecione uma Nota Fiscal.")
206
+ else:
207
+ db = SessionLocal()
208
+ try:
209
+ regs = (
210
+ db.query(Equipamento)
211
+ .filter(Equipamento.nota_fiscal == nf)
212
+ .order_by(Equipamento.id.desc())
213
+ .all()
214
+ )
215
+ except Exception as e:
216
+ regs = []
217
+ st.error(f"Erro ao buscar NF: {e}")
218
+ finally:
219
+ db.close()
220
+
221
+ if not regs:
222
+ st.info("Nenhum registro encontrado para a NF informada.")
223
+ st.session_state.pop("__nf_busca_result__", None)
224
+ st.session_state.pop("__nf_busca_opts__", None)
225
+ else:
226
+ # Persistimos APENAS IDs e um resumo textual (não guardamos objetos SQLAlchemy)
227
+ result = [
228
+ {"id": r.id, "data_coleta": str(r.data_coleta), "fpso": r.fpso or "—", "osm": r.osm or "—"}
229
+ for r in regs
230
+ ]
231
+ opts = [f"ID {x['id']} | {x['data_coleta']} | FPSO {x['fpso']} | OSM {x['osm']}" for x in result]
232
+
233
+ st.session_state["__nf_busca_result__"] = result
234
+ st.session_state["__nf_busca_opts__"] = opts
235
+ # Reset da seleção anterior
236
+ st.session_state["sel_registro_base"] = opts[0] if opts else None
237
+
238
+ # ⬇️ Mostrar seleção + botão USAR sempre que houver resultado persistido
239
+ if st.session_state.get("__nf_busca_opts__"):
240
+ escolha = st.selectbox(
241
+ "Selecione o registro base",
242
+ st.session_state["__nf_busca_opts__"],
243
+ key="sel_registro_base"
244
+ )
245
+
246
+ if st.button("📥 Usar este registro como base", key="btn_usar_base"):
247
+ try:
248
+ # Extrai o ID do texto selecionado
249
+ chosen_id = int(escolha.split()[1])
250
+ except Exception:
251
+ st.error("Falha ao identificar o ID do registro selecionado.")
252
+ chosen_id = None
253
+
254
+ if chosen_id:
255
+ # Busca o registro no banco por ID (seguro e stateless)
256
+ db = SessionLocal()
257
+ try:
258
+ base = db.query(Equipamento).filter(Equipamento.id == chosen_id).first()
259
+ except Exception as e:
260
+ base = None
261
+ st.error(f"Erro ao buscar o registro por ID: {e}")
262
+ finally:
263
+ db.close()
264
+
265
+ if not base:
266
+ st.error("Registro não encontrado. Tente buscar novamente.")
267
+ else:
268
+ pre = _build_prefill_from_record(base)
269
+ if marcar_origem:
270
+ pre["observacoes"] = (pre.get("observacoes") or "") + \
271
+ f" [Clonado do ID {base.id} em {datetime.now():%d/%m/%Y %H:%M}]"
272
+ st.session_state["form_prefill"] = pre
273
+ st.session_state.pop("__prefill_applied__", None)
274
+ st.success("Pré-preenchimento carregado! Role até o formulário para revisar.")
275
+ st.rerun()
276
+
277
+ # ======================================================
278
+ # APLICAR PREFILL (antes de criar os widgets)
279
+ # ======================================================
280
+ prefill = _ss_get("form_prefill", {})
281
+ _apply_prefill_to_state(prefill, debug=debug)
282
+
283
+ if debug:
284
+ with st.expander("🔎 DEBUG • session_state (parcial)"):
285
+ 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")]
286
+ dump = {k: st.session_state[k] for k in sorted(keys)}
287
+ st.write(dump)
288
+
289
+ # ======================================================
290
+ # FORMULÁRIO (SEM st.form)
291
+ # ======================================================
292
+
293
+ # -------- FPSO --------
294
+ st.subheader("🚢 Identificação FPSO")
295
+ c1, c2 = st.columns(2)
296
+
297
+ lista_fpso1 = get_fpsos1() + ["➕ Novo FPSO1"]
298
+ lista_fpso = get_fpsos() + ["➕ Novo FPSO"]
299
+
300
+ # Se valor não está na lista, muda para "➕ Novo …" e mantém texto
301
+ if _ss_get("w_fpso1", "") not in lista_fpso1:
302
+ _ss_set("w_fpso1", "➕ Novo FPSO1" if _ss_get("w_fpso1_text") else "")
303
+ if _ss_get("w_fpso", "") not in lista_fpso:
304
+ _ss_set("w_fpso", "➕ Novo FPSO" if _ss_get("w_fpso_text") else "")
305
+
306
+ with c1:
307
+ st.selectbox(
308
+ "FPSO1 *",
309
+ lista_fpso1,
310
+ index=_safe_index(lista_fpso1, _ss_get("w_fpso1", "")),
311
+ key="w_fpso1"
312
+ )
313
+ if _ss_get("w_fpso1") == "➕ Novo FPSO1":
314
+ st.text_input("Digite o novo FPSO1 *", key="w_fpso1_text")
315
+ fpso1 = _ss_get("w_fpso1_text", "").strip()
316
+ else:
317
+ _ss_set("w_fpso1_text", _ss_get("w_fpso1"))
318
+ fpso1 = _ss_get("w_fpso1")
319
+
320
+ with c2:
321
+ st.selectbox(
322
+ "FPSO *",
323
+ lista_fpso,
324
+ index=_safe_index(lista_fpso, _ss_get("w_fpso", "")),
325
+ key="w_fpso"
326
+ )
327
+ if _ss_get("w_fpso") == "➕ Novo FPSO":
328
+ st.text_input("Digite o novo FPSO *", key="w_fpso_text")
329
+ fpso = _ss_get("w_fpso_text", "").strip()
330
+ else:
331
+ _ss_set("w_fpso_text", _ss_get("w_fpso"))
332
+ fpso = _ss_get("w_fpso")
333
+
334
+ st.divider()
335
+
336
+ # -------- Dados Operacionais --------
337
+ st.subheader("📦 Dados Operacionais")
338
+ c1, c2, c3 = st.columns(3)
339
+
340
+ with c1:
341
+ # 🟢 Usa valor do estado; se vier None, o widget ainda precisa de um date válido.
342
+ st.date_input(
343
+ "Data de Coleta na ARM *",
344
+ value=_ss_get("w_data_coleta") or date.today(),
345
+ key="w_data_coleta"
346
+ )
347
+ st.text_input("Especialista Responsável *", key="w_especialista")
348
+ st.text_input("Conferente Responsável *", key="w_conferente")
349
+ st.text_input("OSM *", key="w_osm")
350
+
351
+ with c2:
352
+ st.selectbox("Modal *", MODAL_LISTA,
353
+ index=_safe_index(MODAL_LISTA, _ss_get("w_modal", "")),
354
+ key="w_modal")
355
+ st.number_input("Quantidade de Equipamentos *", min_value=0, value=_ss_get("w_quant_equip", 0), key="w_quant_equip")
356
+ st.text_input("MROB *", key="w_mrob")
357
+
358
+ with c3:
359
+ st.number_input("Total de Linhas OSM", min_value=0, value=_ss_get("w_linhas_osm", 0), key="w_linhas_osm")
360
+ st.number_input("Total de Linhas MROB", min_value=0, value=_ss_get("w_linhas_mrob", 0), key="w_linhas_mrob")
361
+ st.number_input("Total de Linhas com Erro", min_value=0, value=_ss_get("w_linhas_erros", 0), key="w_linhas_erros")
362
+
363
+ st.divider()
364
+
365
+ # -------- Análise de Erros --------
366
+ st.subheader("⚠️ Análise de Erros")
367
+ c1, c2, c3, c4 = st.columns(4)
368
+ c1.selectbox("Storekeeper", RESP_ERRO_LISTA, index=_safe_index(RESP_ERRO_LISTA, _ss_get("w_erro_storekeeper", "")), key="w_erro_storekeeper")
369
+ c2.selectbox("Operação WH", RESP_ERRO_LISTA, index=_safe_index(RESP_ERRO_LISTA, _ss_get("w_erro_operacao", "")), key="w_erro_operacao")
370
+ c3.selectbox("Especialista WH", RESP_ERRO_LISTA, index=_safe_index(RESP_ERRO_LISTA, _ss_get("w_erro_especialista", "")), key="w_erro_especialista")
371
+ c4.selectbox("Outros", RESP_ERRO_LISTA, index=_safe_index(RESP_ERRO_LISTA, _ss_get("w_erro_outros", "")), key="w_erro_outros")
372
+
373
+ st.selectbox(
374
+ "Inclusão / Exclusão",
375
+ INCLUSAO_EXCLUSAO_LISTA,
376
+ index=_safe_index(INCLUSAO_EXCLUSAO_LISTA, _ss_get("w_inclusao_exclusao", "")),
377
+ key="w_inclusao_exclusao"
378
+ )
379
+
380
+ st.divider()
381
+
382
+ # -------- Dados Administrativos --------
383
+ st.subheader("🧾 Dados Administrativos")
384
+ ca, cb, cc = st.columns(3)
385
+ with ca:
386
+ st.text_input("PO", key="w_po")
387
+ st.text_input("Part Number", key="w_part_number")
388
+ with cb:
389
+ st.text_input("Material", key="w_material")
390
+ st.text_input("Nota Fiscal *", key="w_nota_fiscal")
391
+ with cc:
392
+ st.text_input("Solicitante *", key="w_solicitante")
393
+ st.text_input("Requisitante *", key="w_requisitante")
394
+
395
+ st.text_input("Impacto *", key="w_impacto")
396
+ st.text_input("Dimensão *", key="w_dimensao")
397
+ st.text_input("Motivo da Inclusão / Exclusão", key="w_motivo")
398
+
399
+ st.text_area("Observações", key="w_observacoes", height=120)
400
+
401
+ # -------- Dia D --------
402
+ st.subheader("🗓️ Dia de Inclusão (D)")
403
+ st.selectbox(
404
+ "Selecione o dia",
405
+ D_LISTA,
406
+ index=_safe_index(D_LISTA, _ss_get("w_dia_inclusao", "")),
407
+ key="w_dia_inclusao"
408
+ )
409
+
410
+ # ======================================================
411
+ # SALVAR (sempre nova entrada)
412
+ # ======================================================
413
+ if st.button("💾 Salvar Registro", type="primary"):
414
+ # Lê valores atuais do session_state
415
+ data_coleta = _ss_get("w_data_coleta")
416
+ especialista = _ss_get("w_especialista", "").strip()
417
+ conferente = _ss_get("w_conferente", "").strip()
418
+ osm = _ss_get("w_osm", "").strip()
419
+ modal = _ss_get("w_modal", "")
420
+ quant_equip = int(_ss_get("w_quant_equip", 0) or 0)
421
+ mrob = _ss_get("w_mrob", "").strip()
422
+ linhas_osm = int(_ss_get("w_linhas_osm", 0) or 0)
423
+ linhas_mrob = int(_ss_get("w_linhas_mrob", 0) or 0)
424
+ linhas_erros = int(_ss_get("w_linhas_erros", 0) or 0)
425
+ erro_storekeeper = _ss_get("w_erro_storekeeper", "")
426
+ erro_operacao = _ss_get("w_erro_operacao", "")
427
+ erro_especialista = _ss_get("w_erro_especialista", "")
428
+ erro_outros = _ss_get("w_erro_outros", "")
429
+ inclusao_exclusao = _ss_get("w_inclusao_exclusao", "")
430
+ po = _ss_get("w_po", "").strip()
431
+ part_number = _ss_get("w_part_number", "").strip()
432
+ material = _ss_get("w_material", "").strip()
433
+ solicitante = _ss_get("w_solicitante", "").strip()
434
+ requisitante = _ss_get("w_requisitante", "").strip()
435
+ nota_fiscal = _ss_get("w_nota_fiscal", "").strip()
436
+ impacto = _ss_get("w_impacto", "").strip()
437
+ dimensao = _ss_get("w_dimensao", "").strip()
438
+ motivo = _ss_get("w_motivo", "").strip()
439
+ observacoes = _ss_get("w_observacoes", "").strip()
440
+ dia_inclusao = _ss_get("w_dia_inclusao", "")
441
+
442
+ # Campos FPSO/FPSO1
443
+ fpso1 = _ss_get("w_fpso1_text", "").strip() if _ss_get("w_fpso1") == "➕ Novo FPSO1" else _ss_get("w_fpso1", "").strip()
444
+ fpso = _ss_get("w_fpso_text", "").strip() if _ss_get("w_fpso") == "➕ Novo FPSO" else _ss_get("w_fpso", "").strip()
445
+
446
+ obrigatorios = {
447
+ "FPSO1": fpso1,
448
+ "FPSO": fpso,
449
+ "Especialista": especialista,
450
+ "Conferente": conferente,
451
+ "OSM": osm,
452
+ "Modal": modal,
453
+ "MROB": mrob,
454
+ "Solicitante": solicitante,
455
+ "Requisitante": requisitante,
456
+ "Nota Fiscal": nota_fiscal,
457
+ "Impacto": impacto,
458
+ "Dimensão": dimensao,
459
+ "Dia de Inclusão (D)": dia_inclusao,
460
+ }
461
+ faltantes = [k for k, v in obrigatorios.items() if not v]
462
+ if faltantes:
463
+ st.error("❌ Campos obrigatórios não preenchidos: " + ", ".join(faltantes))
464
+ return
465
+
466
+ db = SessionLocal()
467
+ try:
468
+ novo = Equipamento(
469
+ fpso1=fpso1,
470
+ fpso=fpso,
471
+ data_coleta=data_coleta,
472
+ especialista=especialista,
473
+ conferente=conferente,
474
+ osm=osm,
475
+ modal=modal,
476
+ quant_equip=quant_equip,
477
+ mrob=mrob,
478
+ linhas_osm=linhas_osm,
479
+ linhas_mrob=linhas_mrob,
480
+ linhas_erros=linhas_erros,
481
+ erro_storekeeper=erro_storekeeper,
482
+ erro_operacao=erro_operacao,
483
+ erro_especialista=erro_especialista,
484
+ erro_outros=erro_outros,
485
+ inclusao_exclusao=inclusao_exclusao,
486
+ po=po,
487
+ part_number=part_number,
488
+ material=material,
489
+ solicitante=solicitante,
490
+ motivo=motivo,
491
+ requisitante=requisitante,
492
+ nota_fiscal=nota_fiscal,
493
+ impacto=impacto,
494
+ dimensao=dimensao,
495
+ observacoes=observacoes,
496
+ dia_inclusao=dia_inclusao,
497
+ data_hora_input=datetime.now(),
498
+ )
499
+ db.add(novo)
500
+ db.commit()
501
+
502
+ registrar_log(
503
+ usuario=_ss_get("usuario", "desconhecido"),
504
+ acao="INSERIR",
505
+ tabela="equipamentos",
506
+ registro_id=novo.id
507
+ )
508
+
509
+ st.success("✅ Registro salvo com sucesso!")
510
+ st.rerun()
511
+
512
+ except Exception as e:
513
+ db.rollback()
514
+ st.error(f"❌ Erro ao salvar: {e}")
515
+ finally:
516
+ db.close()
517
+
518
+ if __name__ == "__main__":
519
+ main()
importar_excel.py ADDED
@@ -0,0 +1,301 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import streamlit as st
3
+ import pandas as pd
4
+ from io import BytesIO
5
+ from datetime import datetime, date
6
+
7
+ from banco import SessionLocal
8
+ from models import Equipamento
9
+ from utils_auditoria import registrar_log
10
+
11
+
12
+ # =====================================================
13
+ # FUNÇÕES AUXILIARES
14
+ # =====================================================
15
+ def to_date(value):
16
+ """
17
+ Converte pandas.Timestamp ou datetime para datetime.date
18
+ Necessário para compatibilidade com SQLite
19
+ """
20
+ if value is None or pd.isna(value):
21
+ return None
22
+ if isinstance(value, pd.Timestamp):
23
+ return value.date()
24
+ if isinstance(value, datetime):
25
+ return value.date()
26
+ if isinstance(value, date):
27
+ return value
28
+ return None
29
+
30
+
31
+ def safe_value(value):
32
+ """
33
+ Retorna 0 se o valor for vazio/NaN, senão retorna o próprio valor.
34
+ Usado para campos obrigatórios.
35
+ """
36
+ if value is None or pd.isna(value):
37
+ return 0
38
+ return value
39
+
40
+
41
+ # =====================================================
42
+ # MÓDULO PRINCIPAL
43
+ # =====================================================
44
+ def main():
45
+ st.title("📥 Importação de Dados via Excel")
46
+
47
+ st.markdown(
48
+ """
49
+ Este módulo permite:
50
+ - 📄 Baixar um **modelo Excel padrão**
51
+ - ✍️ Preencher os dados offline
52
+ - 🔍 Validar antes da gravação
53
+ - 💾 Importar os registros para o banco
54
+ """
55
+ )
56
+
57
+ # =====================================================
58
+ # 1️⃣ GERAR MODELO EXCEL
59
+ # =====================================================
60
+ st.subheader("📄 Baixar modelo Excel")
61
+
62
+ colunas = [
63
+ "fpso1", "fpso", "data_coleta", "especialista", "conferente", "osm", "modal",
64
+ "quant_equip", "mrob", "linhas_osm", "linhas_mrob", "linhas_erros",
65
+ "erro_storekeeper", "erro_operacao", "erro_especialista", "erro_outros",
66
+ "inclusao_exclusao", "po", "part_number", "material", "solicitante", "motivo",
67
+ "requisitante", "nota_fiscal", "impacto", "dimensao", "observacoes", "dia_inclusao",
68
+ ]
69
+
70
+ modelo_df = pd.DataFrame(columns=colunas)
71
+
72
+ buffer = BytesIO()
73
+ with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
74
+ modelo_df.to_excel(writer, index=False, sheet_name="MODELO")
75
+
76
+ buffer.seek(0)
77
+
78
+ st.download_button(
79
+ label="⬇️ Baixar modelo Excel",
80
+ data=buffer,
81
+ file_name="modelo_importacao_load.xlsx",
82
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
83
+ )
84
+
85
+ st.divider()
86
+
87
+ # =====================================================
88
+ # 2️⃣ UPLOAD DO ARQUIVO
89
+ # =====================================================
90
+ st.subheader("📤 Importar arquivo preenchido")
91
+
92
+ arquivo = st.file_uploader(
93
+ "Selecione o arquivo Excel",
94
+ type=["xlsx"]
95
+ )
96
+
97
+ if not arquivo:
98
+ st.info("📌 Faça o upload de um arquivo para continuar.")
99
+ return
100
+
101
+ try:
102
+ df = pd.read_excel(arquivo)
103
+ except Exception as e:
104
+ st.error(f"❌ Erro ao ler o arquivo: {e}")
105
+ return
106
+
107
+ # Garante um identificador estável para cada linha durante as edições
108
+ if "_row_id" not in df.columns:
109
+ df["_row_id"] = range(len(df))
110
+
111
+ # Salva o DF bruto na sessão para persistência entre reruns
112
+ st.session_state["df_raw"] = df.copy()
113
+
114
+ st.success("✅ Arquivo carregado com sucesso!")
115
+
116
+ # =====================================================
117
+ # 3️⃣ PRÉVIA DOS DADOS
118
+ # =====================================================
119
+ st.subheader("🔍 Prévia dos dados")
120
+ st.dataframe(df, use_container_width=True)
121
+
122
+ # =====================================================
123
+ # 4️⃣ VERIFICAÇÃO DE DUPLICIDADE (com seleção de linhas a excluir)
124
+ # =====================================================
125
+ st.subheader("🧪 Verificação de duplicidade")
126
+
127
+ st.caption("Escolha as colunas que definem a duplicidade. Em seguida, marque o checkbox **_excluir** nas linhas que **não** deseja importar.")
128
+
129
+ # Sugerimos um conjunto padrão de chaves, mas só usamos as que existem no arquivo
130
+ sugestao_chaves = [c for c in ["fpso", "osm", "po", "part_number", "nota_fiscal", "data_coleta"] if c in df.columns]
131
+
132
+ chaves = st.multiselect(
133
+ "📌 Colunas para verificação de duplicidade:",
134
+ options=list(df.columns),
135
+ default=sugestao_chaves if len(sugestao_chaves) > 0 else []
136
+ )
137
+
138
+ # Prepara um DF de trabalho com colunas auxiliares
139
+ work_df = df.copy()
140
+ work_df["_duplicado"] = False
141
+ work_df["_excluir"] = False # será editado pelo usuário
142
+
143
+ if len(chaves) == 0:
144
+ st.info("Selecione ao menos **uma** coluna para verificar duplicidade.")
145
+ # Mesmo sem duplicidade definida, permitimos marcar exclusões manuais, se quiser:
146
+ with st.expander("🔧 (Opcional) Excluir linhas manualmente mesmo sem duplicidade"):
147
+ manual_view = work_df.set_index("_row_id")[
148
+ [c for c in work_df.columns if c not in ["_duplicado"]] + ["_excluir"]
149
+ ]
150
+ edited_manual = st.data_editor(
151
+ manual_view,
152
+ use_container_width=True,
153
+ num_rows="fixed",
154
+ column_config={
155
+ "_excluir": st.column_config.CheckboxColumn("Excluir da importação")
156
+ }
157
+ )
158
+ # Aplica exclusões manuais
159
+ work_df = work_df.set_index("_row_id")
160
+ work_df["_excluir"] = edited_manual["_excluir"].reindex(work_df.index).fillna(False).astype(bool)
161
+ work_df = work_df.reset_index()
162
+
163
+ else:
164
+ # Marca duplicadas com base nas chaves selecionadas
165
+ mask_dup_any = work_df.duplicated(subset=chaves, keep=False)
166
+ work_df["_duplicado"] = mask_dup_any
167
+
168
+ # Sugerimos exclusão automática das ocorrências não-primárias (o usuário pode alterar)
169
+ mask_dup_not_first = work_df.duplicated(subset=chaves, keep="first")
170
+ work_df.loc[mask_dup_not_first, "_excluir"] = True
171
+
172
+ if mask_dup_any.any():
173
+ st.warning("⚠️ Foram encontradas linhas duplicadas com base nas chaves selecionadas:")
174
+ # Mostrar somente as duplicadas para facilitar a decisão
175
+ cols_para_mostrar = chaves + [c for c in ["_duplicado", "_excluir"] if c not in chaves]
176
+ # Evita colunas repetidas mantendo ordem
177
+ seen = set()
178
+ cols_para_mostrar = [c for c in cols_para_mostrar if not (c in seen or seen.add(c))]
179
+
180
+ dup_view = work_df.loc[mask_dup_any].set_index("_row_id")[cols_para_mostrar]
181
+ edited_dup = st.data_editor(
182
+ dup_view,
183
+ use_container_width=True,
184
+ num_rows="fixed",
185
+ column_config={
186
+ "_excluir": st.column_config.CheckboxColumn("Excluir da importação"),
187
+ "_duplicado": st.column_config.CheckboxColumn("Duplicado", disabled=True)
188
+ }
189
+ )
190
+
191
+ # Mescla de volta as escolhas do usuário
192
+ work_df = work_df.set_index("_row_id")
193
+ if "_excluir" in edited_dup.columns:
194
+ work_df.loc[edited_dup.index, "_excluir"] = (
195
+ edited_dup["_excluir"].reindex(work_df.index).fillna(work_df["_excluir"]).astype(bool)
196
+ )
197
+ work_df = work_df.reset_index()
198
+
199
+ st.info(
200
+ f"📊 Totais — Linhas: {len(work_df)} | Duplicadas: {mask_dup_any.sum()} | "
201
+ f"Marcadas para excluir: {int(work_df['_excluir'].sum())}"
202
+ )
203
+ else:
204
+ st.success("✅ Nenhuma duplicidade encontrada com as chaves selecionadas.")
205
+
206
+ st.divider()
207
+
208
+ # =====================================================
209
+ # 4.1️⃣ Prévia final do que será importado + download
210
+ # =====================================================
211
+ df_para_importar = work_df[~work_df["_excluir"]].drop(columns=["_duplicado", "_excluir"], errors="ignore")
212
+ st.session_state["df_para_importar"] = df_para_importar.copy()
213
+
214
+ st.subheader("🧾 Prévia do que será importado")
215
+ st.caption("A prévia abaixo desconsidera as linhas marcadas como **_excluir**.")
216
+ st.dataframe(df_para_importar.drop(columns=["_row_id"], errors="ignore"), use_container_width=True)
217
+
218
+ # Download da prévia para conferência
219
+ buf_prev = BytesIO()
220
+ with pd.ExcelWriter(buf_prev, engine="openpyxl") as writer:
221
+ df_para_importar.drop(columns=["_row_id"], errors="ignore").to_excel(writer, index=False, sheet_name="A_IMPORTAR")
222
+ buf_prev.seek(0)
223
+ st.download_button(
224
+ "⬇️ Baixar prévia (Excel)",
225
+ data=buf_prev,
226
+ file_name="previa_a_importar.xlsx",
227
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
228
+ help="Baixe a prévia do conjunto que será gravado."
229
+ )
230
+
231
+ # =====================================================
232
+ # 5️⃣ GRAVAÇÃO NO BANCO (usa o DataFrame filtrado)
233
+ # =====================================================
234
+ st.subheader("💾 Gravar dados no banco")
235
+
236
+ col1, col2 = st.columns(2)
237
+
238
+ if col1.button("💾 Salvar registros importados"):
239
+ df_import = st.session_state.get("df_para_importar", df)
240
+
241
+ if df_import.empty:
242
+ st.error("Não há registros para importar. Revise as exclusões.")
243
+ return
244
+
245
+ with SessionLocal() as db:
246
+ try:
247
+ for _, row in df_import.iterrows():
248
+ registro = Equipamento(
249
+ fpso1=safe_value(row.get("fpso1")),
250
+ fpso=safe_value(row.get("fpso")),
251
+ data_coleta=to_date(row.get("data_coleta")),
252
+ especialista=safe_value(row.get("especialista")),
253
+ conferente=safe_value(row.get("conferente")),
254
+ osm=safe_value(row.get("osm")),
255
+ modal=safe_value(row.get("modal")),
256
+ quant_equip=int(row["quant_equip"]) if not pd.isna(row.get("quant_equip")) else 0,
257
+ mrob=safe_value(row.get("mrob")),
258
+ linhas_osm=int(row["linhas_osm"]) if not pd.isna(row.get("linhas_osm")) else 0,
259
+ linhas_mrob=int(row["linhas_mrob"]) if not pd.isna(row.get("linhas_mrob")) else 0,
260
+ linhas_erros=int(row["linhas_erros"]) if not pd.isna(row.get("linhas_erros")) else 0,
261
+ erro_storekeeper=safe_value(row.get("erro_storekeeper")),
262
+ erro_operacao=safe_value(row.get("erro_operacao")),
263
+ erro_especialista=safe_value(row.get("erro_especialista")),
264
+ erro_outros=safe_value(row.get("erro_outros")),
265
+ inclusao_exclusao=safe_value(row.get("inclusao_exclusao")),
266
+ po=safe_value(row.get("po")),
267
+ part_number=safe_value(row.get("part_number")),
268
+ material=safe_value(row.get("material")),
269
+ solicitante=safe_value(row.get("solicitante")),
270
+ motivo=safe_value(row.get("motivo")),
271
+ requisitante=safe_value(row.get("requisitante")),
272
+ nota_fiscal=safe_value(row.get("nota_fiscal")),
273
+ impacto=safe_value(row.get("impacto")),
274
+ dimensao=safe_value(row.get("dimensao")),
275
+ observacoes=safe_value(row.get("observacoes")),
276
+ dia_inclusao=safe_value(row.get("dia_inclusao")),
277
+ )
278
+ db.add(registro)
279
+
280
+ db.commit()
281
+
282
+ registrar_log(
283
+ usuario=st.session_state.get("usuario"),
284
+ acao=f"IMPORTAÇÃO EXCEL ({len(df_import)} registros) - com filtro de duplicidade",
285
+ tabela="equipamentos",
286
+ registro_id=None
287
+ )
288
+
289
+ st.success(f"🎉 Importação concluída com sucesso! {len(df_import)} registros gravados.")
290
+
291
+ except Exception as e:
292
+ db.rollback()
293
+ st.error(f"❌ Erro ao gravar no banco: {e}")
294
+
295
+ if col2.button("❌ Cancelar importação"):
296
+ st.warning("Importação cancelada pelo usuário.")
297
+
298
+
299
+ if __name__ == "__main__":
300
+ main()
301
+
init_admin.py ADDED
@@ -0,0 +1,33 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # init_admin.py
2
+ from banco import SessionLocal, engine, Base
3
+ from models import Usuario
4
+ from werkzeug.security import generate_password_hash
5
+
6
+ def criar_admin():
7
+ Base.metadata.create_all(bind=engine)
8
+
9
+ db = SessionLocal()
10
+
11
+ admin = db.query(Usuario).filter(Usuario.usuario == "admin").first()
12
+
13
+ if admin:
14
+ print("✔ Usuário admin já existe")
15
+ return
16
+
17
+ admin = Usuario(
18
+ usuario="admin",
19
+ senha=generate_password_hash("admin123"),
20
+ perfil="admin",
21
+ ativo=True
22
+ )
23
+
24
+ db.add(admin)
25
+ db.commit()
26
+ db.close()
27
+
28
+ print("✅ Usuário admin criado com sucesso")
29
+ print("Login: admin")
30
+ print("Senha: admin123")
31
+
32
+ if __name__ == "__main__":
33
+ criar_admin()
init_db.py ADDED
@@ -0,0 +1,38 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from banco import engine, SessionLocal, Base
2
+ import models
3
+ from models import Usuario
4
+ from utils_seguranca import gerar_hash_senha
5
+
6
+ # Cria todas as tabelas definidas nos modelos
7
+ Base.metadata.create_all(bind=engine)
8
+
9
+ db = SessionLocal()
10
+
11
+ try:
12
+ usuarios_padrao = [
13
+ ("admin", "admin123", "admin"),
14
+ ("usuario", "usuario123", "usuario"),
15
+ ("consulta", "consulta123", "consulta"),
16
+ ]
17
+
18
+ for nome, senha, perfil in usuarios_padrao:
19
+ existe = db.query(Usuario).filter(Usuario.usuario == nome).first()
20
+
21
+ if not existe:
22
+ novo = Usuario(
23
+ usuario=nome,
24
+ senha=gerar_hash_senha(senha),
25
+ perfil=perfil,
26
+ ativo=True
27
+ )
28
+ db.add(novo)
29
+ print(f"✅ Usuário '{nome}' criado")
30
+ else:
31
+ print(f"ℹ️ Usuário '{nome}' já existe")
32
+
33
+ db.commit()
34
+
35
+ finally:
36
+ db.close()
37
+
38
+ print("✅ Banco inicializado com sucesso!")
jogos.py ADDED
@@ -0,0 +1,384 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import streamlit as st
3
+ import random
4
+ import unicodedata
5
+ from datetime import date, timedelta # ✅ para controle diário de tentativas falhas
6
+
7
+
8
+ def reset_keys(keys):
9
+ for k in keys:
10
+ st.session_state.pop(k, None)
11
+
12
+
13
+ def strip_accents(s: str) -> str: # ✅ corrigido '->' (sem entidades HTML)
14
+ if not isinstance(s, str):
15
+ return s
16
+ return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
17
+
18
+
19
+ # ✅ Helper de estado: garantia defensiva (executa sem impactar sua lógica)
20
+ def _init_estado_jogos():
21
+ st.session_state.setdefault("pontuacao", 0)
22
+ st.session_state.setdefault("rodadas", 0)
23
+ st.session_state.setdefault("ultimo_resultado", None)
24
+ # ⬇️ NOVO (Dado): memória de resultados já vistos por configuração de lados
25
+ st.session_state.setdefault("dado_resultados_vistos", [])
26
+ st.session_state.setdefault("dado_lados_atual", None)
27
+ # ⬇️ NOVO (Forca): palavras já concluídas por categoria (evita pontuar repetidas)
28
+ # Estrutura: { "FPSO": ["INSPECAO VISUAL", ...], "Estoque e Armazenagens": [...], ... }
29
+ st.session_state.setdefault("forca_palavras_vistas", {})
30
+ # ⬇️ NOVO (Forca): letras já clicadas (apagadas/desativadas no teclado)
31
+ st.session_state.setdefault("forca_letras_usadas", set())
32
+ # ⬇️ NOVO (Forca): controle diário por rodada perdida (tentativa de jogo falha)
33
+ st.session_state.setdefault("forca_falhas_hoje", 0)
34
+ st.session_state.setdefault("forca_block_expires_on", None)
35
+ st.session_state.setdefault("forca_data_ref", str(date.today()))
36
+ st.session_state.setdefault("forca_round_contabilizado", False) # marca se a perda já foi contabilizada nesta rodada
37
+ # ⬇️ NOVO (Tesouro): perguntas já concluídas por categoria (evita pontuar repetidas)
38
+ # Estrutura: { "FPSO": ["Identifique o item correto...", ...], ... }
39
+ st.session_state.setdefault("tesouro_perguntas_vistas", {})
40
+
41
+
42
+ # Mantém inicialização que você tinha (compatível com o helper)
43
+ if "pontuacao" not in st.session_state:
44
+ st.session_state.pontuacao = 0
45
+
46
+
47
+ FORCA_BANK = [
48
+ {
49
+ "categoria": "FPSO",
50
+ "pergunta": "Qual procedimento prioriza a integridade de carga no deck?",
51
+ "resposta": "INSPEÇÃO VISUAL",
52
+ },
53
+ {
54
+ "categoria": "Estoque e Armazenagens",
55
+ "pergunta": "Qual política prioriza itens com menor validade remanescente?",
56
+ "resposta": "FEFO",
57
+ },
58
+ {
59
+ "categoria": "Óleo e Gás",
60
+ "pergunta": "Qual documento acompanha movimentação de resíduos perigosos?",
61
+ "resposta": "MANIFESTO DE RESÍDUOS",
62
+ },
63
+ ]
64
+
65
+ TESOURO_BANK = [
66
+ {
67
+ "categoria": "FPSO",
68
+ "pergunta": "Identifique o item correto para amarração segura no deck.",
69
+ "pistas": [
70
+ {"texto": "Requer inspeção visual antes do uso.", "correto": True},
71
+ {"texto": "É descartável após uma operação.", "correto": False},
72
+ {"texto": "Possui etiqueta de carga segura com WLL.", "correto": True},
73
+ {"texto": "Não pode ser usado em ambiente offshore.", "correto": False},
74
+ ],
75
+ },
76
+ {
77
+ "categoria": "Estoque e Armazenagens",
78
+ "pergunta": "Determine a política correta para expedição de produtos com validade.",
79
+ "pistas": [
80
+ {"texto": "Prioriza vencimento mais próximo.", "correto": True},
81
+ {"texto": "Ignora datas de validade.", "correto": False},
82
+ ],
83
+ },
84
+ ]
85
+
86
+
87
+ def jogo_dado():
88
+ _init_estado_jogos() # ✅ garante chaves antes do uso
89
+ st.subheader("🎲 Jogo do Dado (Curiosidades)")
90
+
91
+ curiosidades = [
92
+ "FIFO e FEFO impactam diretamente a acuracidade de estoque.",
93
+ "Cross-docking reduz tempos e evita armazenagem desnecessária.",
94
+ "WMS integra endereçamento, picking e inventários cíclicos.",
95
+ ]
96
+
97
+ lados = st.slider("Escolha o número de lados do dado:", 6, 20, 8)
98
+
99
+ # ⬇️ NOVO: ao alterar o número de lados, resetamos os resultados vistos para esta configuração
100
+ if st.session_state.dado_lados_atual != lados:
101
+ st.session_state.dado_lados_atual = lados
102
+ st.session_state.dado_resultados_vistos = [] # limpa histórico para a nova configuração de lados
103
+
104
+ if st.button("Girar dado"):
105
+ resultado = random.randint(1, lados)
106
+ st.success(f"🎲 Você rolou: {resultado}")
107
+ st.info(curiosidades[resultado % len(curiosidades)])
108
+
109
+ # ⬇️ NOVO: só pontua se o resultado ainda NÃO tiver saído nesta configuração de lados
110
+ if resultado in st.session_state.dado_resultados_vistos:
111
+ st.warning("🔁 Resultado repetido — nenhum ponto adicionado ao ranking.")
112
+ else:
113
+ st.session_state.dado_resultados_vistos.append(resultado)
114
+ st.session_state.pontuacao += 5
115
+ st.balloons()
116
+
117
+ # Informações úteis
118
+ if st.session_state.dado_resultados_vistos:
119
+ vistos_fmt = ", ".join(str(v) for v in sorted(st.session_state.dado_resultados_vistos))
120
+ st.caption(f"🔎 Valores únicos já obtidos com {lados} lados: {vistos_fmt}")
121
+
122
+ st.write(f"Pontuação atual: {st.session_state.pontuacao}")
123
+
124
+
125
+ def jogo_forca_treinamento():
126
+ _init_estado_jogos() # ✅ garante chaves antes do uso
127
+ st.subheader("🔤 Jogo da Forca (Treinamento)")
128
+
129
+ # 🔧 Reset diário dos contadores/bloqueio por rodada perdida
130
+ if st.session_state.get("forca_data_ref") != str(date.today()):
131
+ st.session_state.forca_data_ref = str(date.today())
132
+ st.session_state.forca_falhas_hoje = 0
133
+ st.session_state.forca_block_expires_on = None
134
+
135
+ # 🔒 Se bloqueado até amanhã, impedir jogar
136
+ block_until = st.session_state.get("forca_block_expires_on")
137
+ if block_until and date.today() < block_until:
138
+ st.error(f"⏳ Você atingiu 3 tentativas falhas hoje. Tente novamente em {block_until.strftime('%d/%m/%Y')}.")
139
+ st.write(f"Pontuação atual: {st.session_state.pontuacao}")
140
+ return
141
+
142
+ categorias = sorted(set(q["categoria"] for q in FORCA_BANK))
143
+ cat_sel = st.selectbox("Categoria:", ["Todas"] + categorias, index=0)
144
+
145
+ banco_filtrado = [q for q in FORCA_BANK if cat_sel == "Todas" or q["categoria"] == cat_sel]
146
+ total = len(banco_filtrado)
147
+
148
+ # Reset ao trocar categoria ou na primeira carga
149
+ if "forca_idx" not in st.session_state or st.session_state.get("forca_cat") != cat_sel:
150
+ st.session_state.forca_idx = 0
151
+ st.session_state.forca_cat = cat_sel
152
+ reset_keys(["forca_palavra", "letras_descobertas", "tentativas", "letras_erradas", "forca_input", "forca_letras_usadas", "forca_round_contabilizado"])
153
+
154
+ if not banco_filtrado:
155
+ st.warning("Não há perguntas para esta categoria.")
156
+ return
157
+
158
+ nivel = st.session_state.forca_idx % total
159
+ st.write(f"Progresso: Pergunta {nivel + 1}/{total}")
160
+ atual = banco_filtrado[nivel]
161
+ pergunta, resposta = atual["pergunta"], atual["resposta"].upper()
162
+ categoria_atual = atual["categoria"]
163
+
164
+ # Inicializa rodada da forca
165
+ if "forca_palavra" not in st.session_state or st.session_state.get("forca_palavra") != resposta:
166
+ st.session_state.forca_palavra = resposta
167
+ st.session_state.letras_descobertas = ["_" if c.isalpha() else c for c in resposta]
168
+ st.session_state.tentativas = 6
169
+ st.session_state.letras_erradas = []
170
+ st.session_state.forca_letras_usadas = set() # ⬅️ inicia teclado limpo
171
+ st.session_state.forca_round_contabilizado = False # ⬅️ nova rodada ainda não contabilizada como falha
172
+
173
+ st.write(f"Pergunta: {pergunta}")
174
+ st.write("Palavra:", " ".join(st.session_state.letras_descobertas))
175
+ st.write(f"Tentativas restantes: {st.session_state.tentativas}")
176
+
177
+ # ==============================
178
+ # 🔤 NOVO: TECLADO DE ALFABETO (compacto)
179
+ # ==============================
180
+ # CSS para deixar botões mais juntos (afeta globalmente — usar com parcimônia)
181
+ st.markdown(
182
+ """
183
+ <style>
184
+ div.stButton > button {
185
+ padding: 6px 8px !important;
186
+ margin: 2px !important;
187
+ font-size: 14px !important;
188
+ }
189
+ </style>
190
+ """,
191
+ unsafe_allow_html=True
192
+ )
193
+
194
+ # A–Z; comparação sem acentos (ex.: 'O' casa 'Ó', 'C' casa 'Ç')
195
+ alfabeto = list("ABCDEFGHIJKLMNOPQRSTUVWXYZ")
196
+
197
+ # Duas linhas com 13 letras cada (mais compacto)
198
+ linhas = [
199
+ alfabeto[0:13],
200
+ alfabeto[13:26],
201
+ ]
202
+
203
+ for idx_linha, linha in enumerate(linhas):
204
+ cols = st.columns(len(linha))
205
+ for idx_letra, letra in enumerate(linha):
206
+ usada = letra in st.session_state.forca_letras_usadas
207
+ if cols[idx_letra].button(letra, key=f"forca_btn_{nivel}_{letra}", disabled=usada):
208
+ # Marca como usada (apagada)
209
+ st.session_state.forca_letras_usadas.add(letra)
210
+
211
+ # Verifica se a letra existe na palavra (comparando sem acentos)
212
+ hit = False
213
+ for i, ch in enumerate(st.session_state.forca_palavra):
214
+ if strip_accents(ch) == strip_accents(letra):
215
+ st.session_state.letras_descobertas[i] = ch
216
+ hit = True
217
+
218
+ if not hit:
219
+ # Letra errada: registra e decrementa tentativa
220
+ if letra not in st.session_state.letras_erradas:
221
+ st.session_state.letras_erradas.append(letra)
222
+ st.session_state.tentativas -= 1
223
+
224
+ # Rerender após o clique para atualizar visualmente a palavra e tentativas
225
+ st.rerun()
226
+
227
+ # Feedback de letras erradas (visível)
228
+ if st.session_state.letras_erradas:
229
+ st.caption(f"❌ Letras erradas: {', '.join(st.session_state.letras_erradas)}")
230
+
231
+ venceu = "_" not in st.session_state.letras_descobertas
232
+ perdeu = st.session_state.tentativas <= 0 # ✅ corrigido '<=' (sem entidades HTML)
233
+
234
+ # ⬇️ NOVO: prepara lista de palavras já vistas por categoria
235
+ # normaliza removendo acentos para evitar duplicidades por grafia
236
+ normalizada = strip_accents(resposta)
237
+
238
+ if venceu:
239
+ # Checa repetição por categoria e evita somar pontos
240
+ vistas = st.session_state.forca_palavras_vistas.get(categoria_atual, [])
241
+ if normalizada in vistas:
242
+ st.warning("🔁 Palavra repetida nesta categoria — nenhum ponto adicionado ao ranking.")
243
+ else:
244
+ vistas.append(normalizada)
245
+ st.session_state.forca_palavras_vistas[categoria_atual] = vistas
246
+ st.success("🎉 Você venceu! Palavra completa.")
247
+ st.session_state.pontuacao += 10
248
+ st.balloons()
249
+
250
+ # Mostrar resumo de palavras únicas concluídas nesta categoria
251
+ if st.session_state.forca_palavras_vistas.get(categoria_atual):
252
+ lista_fmt = ", ".join(st.session_state.forca_palavras_vistas[categoria_atual])
253
+ st.caption(f"🔎 Palavras únicas concluídas ({categoria_atual}): {lista_fmt}")
254
+
255
+ if nivel + 1 < total: # ✅ corrigido '<' (sem entidades HTML)
256
+ if st.button("➡️ Próxima pergunta"):
257
+ st.session_state.forca_idx += 1
258
+ reset_keys(["forca_palavra", "letras_descobertas", "tentativas", "letras_erradas", "forca_input", "forca_letras_usadas", "forca_round_contabilizado"])
259
+ st.rerun()
260
+ else:
261
+ st.success("🏆 Você finalizou todas as perguntas do jogo da Forca!")
262
+
263
+ elif perdeu:
264
+ # ✅ NOVO: contabiliza tentativa falha (rodada perdida) uma única vez
265
+ if not st.session_state.forca_round_contabilizado:
266
+ st.session_state.forca_falhas_hoje += 1
267
+ st.session_state.forca_round_contabilizado = True
268
+
269
+ # 🔒 Bloqueia após 3 tentativas falhas no dia
270
+ if st.session_state.forca_falhas_hoje >= 3:
271
+ st.session_state.forca_block_expires_on = date.today() + timedelta(days=1)
272
+ st.error(
273
+ f"⏳ Limite diário atingido (3 tentativas falhas). "
274
+ f"Tente novamente em {st.session_state.forca_block_expires_on.strftime('%d/%m/%Y')}."
275
+ )
276
+
277
+ st.error(f"💀 Você perdeu! A palavra era: {st.session_state.forca_palavra}")
278
+
279
+ st.write(f"Pontuação atual: {st.session_state.pontuacao}")
280
+
281
+
282
+ def jogo_tesouro_niveis():
283
+ _init_estado_jogos() # ✅ garante chaves antes do uso
284
+ st.subheader("🗺️ Caça ao Tesouro (Níveis)")
285
+
286
+ categorias = sorted(set(q["categoria"] for q in TESOURO_BANK))
287
+ cat_sel = st.selectbox("Categoria:", ["Todas"] + categorias, index=0)
288
+
289
+ banco_filtrado = [q for q in TESOURO_BANK if cat_sel == "Todas" or q["categoria"] == cat_sel]
290
+ total = min(100, len(banco_filtrado))
291
+
292
+ # Reset ao trocar categoria ou primeira carga
293
+ if "tes_idx" not in st.session_state or st.session_state.get("tes_cat") != cat_sel:
294
+ st.session_state.tes_idx = 0
295
+ st.session_state.tes_cat = cat_sel
296
+ reset_keys(["tes_respostas", "tes_concluido"])
297
+
298
+ if total == 0:
299
+ st.warning("Não há perguntas para esta categoria.")
300
+ return
301
+
302
+ nivel = st.session_state.tes_idx % total
303
+ st.write(f"Progresso: Nível {nivel + 1}/{total}")
304
+ atual = banco_filtrado[nivel]
305
+ pergunta_atual = atual["pergunta"]
306
+ categoria_atual = atual["categoria"]
307
+ st.write(f"Pergunta: {pergunta_atual}")
308
+
309
+ if "tes_respostas" not in st.session_state:
310
+ st.session_state.tes_respostas = {i: None for i in range(len(atual["pistas"]))}
311
+ st.session_state.tes_concluido = False
312
+
313
+ for i, pista in enumerate(atual["pistas"]):
314
+ cols = st.columns([6, 1, 1])
315
+ cols[0].write(f"🧩 {pista['texto']}")
316
+ if cols[1].button("Sim", key=f"tes_sim_{nivel}_{i}"):
317
+ st.session_state.tes_respostas[i] = True
318
+ if cols[2].button("Não", key=f"tes_nao_{nivel}_{i}"):
319
+ st.session_state.tes_respostas[i] = False
320
+
321
+ r = st.session_state.tes_respostas[i]
322
+ if r is not None:
323
+ if r == pista["correto"]:
324
+ cols[0].success("✅ Correto")
325
+ else:
326
+ cols[0].error("❌ Incorreto")
327
+
328
+ tudo_respondido = all(v is not None for v in st.session_state.tes_respostas.values())
329
+ tudo_correto = tudo_respondido and all(
330
+ st.session_state.tes_respostas[i] == p["correto"] for i, p in enumerate(atual["pistas"])
331
+ )
332
+
333
+ # ⬇️ NOVO: prepara lista de perguntas já concluídas por categoria
334
+ pergunta_norm = strip_accents(pergunta_atual).upper()
335
+
336
+ if tudo_correto:
337
+ st.session_state.tes_concluido = True
338
+
339
+ # Checa repetição por categoria e evita somar pontos
340
+ vistas = st.session_state.tesouro_perguntas_vistas.get(categoria_atual, [])
341
+ if pergunta_norm in vistas:
342
+ st.warning("🔁 Nível/pergunta já concluído anteriormente — nenhum ponto adicionado ao ranking.")
343
+ else:
344
+ vistas.append(pergunta_norm)
345
+ st.session_state.tesouro_perguntas_vistas[categoria_atual] = vistas
346
+ st.success("🎉 Parabéns! Nível concluído.")
347
+ st.session_state.pontuacao += 10
348
+ st.balloons()
349
+
350
+ # Mostrar resumo de perguntas únicas concluídas nesta categoria
351
+ if st.session_state.tesouro_perguntas_vistas.get(categoria_atual):
352
+ lista_fmt = ", ".join(st.session_state.tesouro_perguntas_vistas[categoria_atual])
353
+ st.caption(f"🔎 Perguntas únicas concluídas ({categoria_atual}): {lista_fmt}")
354
+
355
+ if nivel + 1 < total: # ✅ corrigido '<' (sem entidades HTML)
356
+ if st.button("➡️ Avançar para o próximo nível"):
357
+ st.session_state.tes_idx += 1
358
+ reset_keys(["tes_respostas", "tes_concluido"])
359
+ st.experimental_rerun()
360
+ else:
361
+ st.success("🏆 Você finalizou todos os níveis do Caça ao Tesouro!")
362
+
363
+ st.write(f"Pontuação atual: {st.session_state.pontuacao}")
364
+
365
+
366
+ def main():
367
+ _init_estado_jogos() # ✅ garante estado ao entrar na página Jogos
368
+ st.title("🎮 Jogos Interativos para Treinamento")
369
+
370
+ jogo = st.selectbox(
371
+ "Escolha um jogo:",
372
+ ["Jogo do Dado (Curiosidades)", "Jogo da Forca (Treinamento)", "Caça ao Tesouro (Níveis)"],
373
+ )
374
+
375
+ if jogo == "Jogo do Dado (Curiosidades)":
376
+ jogo_dado()
377
+ elif jogo == "Jogo da Forca (Treinamento)":
378
+ jogo_forca_treinamento()
379
+ elif jogo == "Caça ao Tesouro (Níveis)":
380
+ jogo_tesouro_niveis()
381
+
382
+
383
+ if __name__ == "__main__":
384
+ main()
listar_perguntas.py ADDED
@@ -0,0 +1,22 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from banco import SessionLocal
2
+ from models import QuizPergunta, QuizResposta
3
+
4
+ def listar_perguntas_respostas():
5
+ db = SessionLocal()
6
+ try:
7
+ perguntas = db.query(QuizPergunta).filter(QuizPergunta.ativo == True).all()
8
+ for p in perguntas:
9
+ print(f"Pergunta (id={p.id}): {p.pergunta}")
10
+ respostas = db.query(QuizResposta).filter(QuizResposta.pergunta_id == p.id).all()
11
+ if not respostas:
12
+ print(" *** SEM RESPOSTAS CADASTRADAS ***")
13
+ continue
14
+ for r in respostas:
15
+ correta = " (correta)" if r.correta else ""
16
+ print(f" - Resposta (id={r.id}): {r.texto}{correta}")
17
+ print()
18
+ finally:
19
+ db.close()
20
+
21
+ if __name__ == "__main__":
22
+ listar_perguntas_respostas()
log.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ from datetime import datetime
3
+ import logging
4
+ from banco import SessionLocal
5
+ from models import LogAcesso
6
+
7
+ # Configuração mínima de logger (opcional; mantenha neutro)
8
+ logger = logging.getLogger(__name__)
9
+
10
+ def registrar_log(usuario, acao, tabela, registro_id=None):
11
+ """
12
+ Registra um log de acesso/ação na tabela LogAcesso.
13
+ Mantém assinatura e padrão original, mas com tratamento de exceções e normalização.
14
+
15
+ :param usuario: str | usuário responsável pela ação (fallback para 'sistema' se None/vazio)
16
+ :param acao: str | ação realizada (ex.: 'CRIAR', 'DESATIVAR', 'EXCLUIR')
17
+ :param tabela: str | nome da tabela/entidade relacionada (ex.: 'eventos_calendario')
18
+ :param registro_id: int | id do registro afetado (opcional)
19
+ """
20
+ # Normalização defensiva (mantendo comportamento atual)
21
+ usuario = (usuario or "sistema")
22
+ acao = (acao or "").strip() or "DESCONHECIDA"
23
+ tabela = (tabela or "").strip() or "desconhecida"
24
+
25
+ db = SessionLocal()
26
+ try:
27
+ log = LogAcesso(
28
+ usuario=usuario,
29
+ acao=acao,
30
+ tabela=tabela,
31
+ registro_id=registro_id,
32
+ data_hora=datetime.now()
33
+ )
34
+ db.add(log)
35
+ db.commit()
36
+ except Exception as e:
37
+ # Em caso de falha, desfazer transação e registrar o problema no logger
38
+ try:
39
+ db.rollback()
40
+ except Exception:
41
+ # Se rollback também falhar, seguimos fechando a sessão
42
+ pass
43
+ logger.exception(
44
+ "Falha ao registrar log: usuario=%r, acao=%r, tabela=%r, registro_id=%r. Erro: %s",
45
+ usuario, acao, tabela, registro_id, e
46
+ )
47
+ # Não propaga exceção: evita quebrar o fluxo da aplicação
48
+ finally:
49
+ db.close()
50
+
51
+
52
+ # ✅ Opcional: uma versão 'safe' que nunca lança erro para o chamador.
53
+ # Mantém o padrão existente; use quando o log não pode interromper a operação.
54
+ def registrar_log_safe(usuario, acao, tabela, registro_id=None):
55
+ try:
56
+ registrar_log(usuario, acao, tabela, registro_id)
57
+ except Exception:
58
+ # Defesa adicional: se algo escapar do registrar_log, não quebra o chamador
59
+ logger.exception(
60
+ "registrar_log_safe: erro inesperado ao registrar log (usuario=%r, acao=%r, tabela=%r, registro_id=%r)",
61
+ usuario, acao, tabela, registro_id
62
+ )
63
+
login.py ADDED
@@ -0,0 +1,175 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ from banco import SessionLocal
5
+ from models import Usuario
6
+ from utils_seguranca import verificar_senha
7
+ from utils_auditoria import registrar_log
8
+
9
+ # 🔀 Roteador de banco (Produção/Teste/Treinamento)
10
+ # Observação: este import assume que db_router.py está na raiz do projeto.
11
+ # Se ainda não existir, usamos um fallback suave (default 'prod').
12
+ try:
13
+ from db_router import set_db_choice, current_db_choice, list_banks, bank_label
14
+ _HAS_ROUTER = True
15
+ except Exception:
16
+ _HAS_ROUTER = False
17
+ def set_db_choice(choice: str):
18
+ st.session_state["__db_choice__"] = (choice or "prod").lower()
19
+ def current_db_choice() -> str:
20
+ return st.session_state.get("__db_choice__", "prod")
21
+ def list_banks():
22
+ return ["prod", "test"] # fallback básico
23
+ def bank_label(choice: str) -> str:
24
+ return {"prod": "Banco 1 (📗 Produção)", "test": "Banco 2 (📕 Teste)"}.get(choice, choice)
25
+
26
+
27
+ def _mostrar_efeito_aniversario(nome: str):
28
+ """Exibe imediatamente efeito e mensagem central de aniversário."""
29
+ # Efeito visual (confete e balões)
30
+ st.snow()
31
+ st.balloons()
32
+
33
+ # Mensagem centralizada
34
+ st.markdown(
35
+ f"""
36
+ <div style="
37
+ display:flex;align-items:center;justify-content:center;
38
+ text-align:center;margin:40px 0 20px 0;">
39
+ <div style="
40
+ font-size: 32px; font-weight: 800; color:#A020F0;
41
+ background:linear-gradient(90deg,#FFF0F6,#F0E6FF);
42
+ padding:16px 24px;border-radius:16px;box-shadow:0 4px 10px rgba(0,0,0,.08);">
43
+ 🎉 Feliz Aniversário, {nome}! 🎉
44
+ </div>
45
+ </div>
46
+ """,
47
+ unsafe_allow_html=True
48
+ )
49
+ st.caption("Desejamos a você muitas conquistas e bons embarques ao longo do ano! 💜")
50
+
51
+
52
+ def login():
53
+ st.subheader("🔐 Login")
54
+
55
+ # ✅ Seleção do banco (dinâmica) — armazenado em sessão para toda a navegação
56
+ # Mantém experiência simples e clara para o usuário.
57
+ banks = list_banks() # ex.: ['prod', 'test', 'treinamento']
58
+ labels = [bank_label(b) for b in banks] # rótulos amigáveis para UI
59
+ idx_default = banks.index("prod") if "prod" in banks else 0
60
+
61
+ banco_label = st.selectbox("Usar banco:", labels, index=idx_default)
62
+ db_choice = banks[labels.index(banco_label)]
63
+ set_db_choice(db_choice)
64
+
65
+ # (Opcional) Indicação visual do banco ativo na sidebar
66
+ # Usando ícones diferentes para cada ambiente:
67
+ ambiente = current_db_choice()
68
+ if ambiente == "prod":
69
+ badge = "🟢 Produção"
70
+ elif ambiente == "test":
71
+ badge = "🔴 Teste"
72
+ elif ambiente == "treinamento":
73
+ badge = "🔵 Treinamento"
74
+ else:
75
+ badge = ambiente
76
+ st.sidebar.caption(f"🗄️ Banco ativo: {badge}")
77
+
78
+ # Campos de credencial
79
+ usuario_input = st.text_input("Usuário")
80
+ senha_input = st.text_input("Senha", type="password")
81
+
82
+ # 🔘 Botão de entrada (mantendo seu fluxo)
83
+ if st.button("Entrar", type="primary"):
84
+ db = SessionLocal()
85
+ try:
86
+ usuario_db = (
87
+ db.query(Usuario)
88
+ .filter(
89
+ Usuario.usuario == usuario_input,
90
+ Usuario.ativo == True
91
+ )
92
+ .first()
93
+ )
94
+
95
+ if not usuario_db or not verificar_senha(senha_input, usuario_db.senha):
96
+ st.error("❌ Usuário ou senha inválidos.")
97
+
98
+ # 📝 Auditoria — registra também o ambiente atual (prod/test/treinamento)
99
+ try:
100
+ registrar_log(
101
+ usuario=usuario_input,
102
+ acao="Tentativa de login inválida",
103
+ tabela="usuarios",
104
+ ambiente=current_db_choice()
105
+ )
106
+ except Exception:
107
+ # Não quebra o fluxo se auditoria falhar
108
+ pass
109
+
110
+ return
111
+
112
+ # ✅ LOGIN OK
113
+ st.session_state.logado = True
114
+ st.session_state.usuario = usuario_db.usuario
115
+ st.session_state.perfil = usuario_db.perfil
116
+
117
+ # ✅ Armazenar e-mail e, se disponível, nome (para exibição na UI)
118
+ # Obs.: getattr evita erro caso o campo não exista ou esteja nulo.
119
+ st.session_state.email = getattr(usuario_db, "email", None)
120
+ st.session_state.nome = getattr(usuario_db, "nome", None)
121
+
122
+ # 🔁 IMPORTANTE: força revalidação do quiz
123
+ st.session_state.quiz_verificado = False
124
+
125
+ # 📝 Auditoria de sucesso — registra o ambiente
126
+ try:
127
+ registrar_log(
128
+ usuario=usuario_db.usuario,
129
+ acao="Login realizado com sucesso",
130
+ tabela="usuarios",
131
+ registro_id=usuario_db.id,
132
+ ambiente=current_db_choice()
133
+ )
134
+ except Exception:
135
+ pass
136
+
137
+ # 🎂 NOVO: checagem de data de aniversário (mês/dia), compatível com Date e ISO string
138
+ try:
139
+ from datetime import date as _date
140
+
141
+ def _to_date_safe(val):
142
+ if not val:
143
+ return None
144
+ # já é 'date'?
145
+ if isinstance(val, _date):
146
+ return val
147
+ # tenta converter de string ISO "YYYY-MM-DD"
148
+ try:
149
+ yy, mm, dd = map(int, str(val).split("-"))
150
+ return _date(yy, mm, dd)
151
+ except Exception:
152
+ return None
153
+
154
+ dn = _to_date_safe(getattr(usuario_db, "data_aniversario", None))
155
+ hoje = _date.today()
156
+
157
+ if dn and (dn.month == hoje.month and dn.day == hoje.day):
158
+ # Mostra efeito imediatamente (antes do rerun)
159
+ nome_exibir = st.session_state.get("nome") or st.session_state.get("usuario") or "Usuário"
160
+ _mostrar_efeito_aniversario(nome_exibir)
161
+
162
+ # Opcional: também sinaliza para main() caso queira reapresentar em outra área
163
+ st.session_state["__show_birthday__"] = True
164
+ except Exception:
165
+ # Não impede o login se algo falhar nessa checagem
166
+ pass
167
+
168
+ st.success("✅ Login realizado com sucesso!")
169
+ st.rerun()
170
+
171
+ finally:
172
+ db.close()
173
+
174
+
175
+
models.py ADDED
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ from sqlalchemy import (
3
+ Column,
4
+ Integer,
5
+ String,
6
+ Date,
7
+ DateTime,
8
+ Boolean,
9
+ ForeignKey,
10
+ Text
11
+ )
12
+ from sqlalchemy.orm import relationship
13
+ # If you face cyclic import issues, place Base here via declarative_base:
14
+ # from sqlalchemy.orm import declarative_base
15
+ # Base = declarative_base()
16
+ from datetime import datetime
17
+ from banco import Base
18
+ from sqlalchemy.sql import func # server_default em AvisoGlobal
19
+
20
+ # =====================================================
21
+ # TABELA EQUIPAMENTOS
22
+ # =====================================================
23
+ class Equipamento(Base):
24
+ __tablename__ = "equipamentos"
25
+
26
+ id = Column(Integer, primary_key=True, index=True)
27
+
28
+ # Identificação
29
+ fpso1 = Column(String, index=True, nullable=False)
30
+ fpso = Column(String, index=True, nullable=False)
31
+ data_coleta = Column(String, index=True, nullable=False) # sugestão futura: Date
32
+
33
+ # Responsáveis
34
+ especialista = Column(String, nullable=False)
35
+ conferente = Column(String, nullable=False)
36
+ osm = Column(String, nullable=False)
37
+
38
+ # Operacional
39
+ modal = Column(String, index=True, nullable=False)
40
+ quant_equip = Column(Integer, default=0)
41
+ mrob = Column(String, nullable=False)
42
+
43
+ # Métricas
44
+ linhas_osm = Column(Integer, default=0)
45
+ linhas_mrob = Column(Integer, default=0)
46
+ linhas_erros = Column(Integer, default=0)
47
+
48
+ # Erros
49
+ erro_storekeeper = Column(String)
50
+ erro_operacao = Column(String)
51
+ erro_especialista = Column(String)
52
+ erro_outros = Column(String)
53
+
54
+ # Dados complementares
55
+ inclusao_exclusao = Column(String)
56
+ po = Column(String)
57
+ part_number = Column(String)
58
+ material = Column(String)
59
+
60
+ solicitante = Column(String, nullable=False)
61
+ motivo = Column(String)
62
+ requisitante = Column(String, nullable=False)
63
+ nota_fiscal = Column(String, nullable=False)
64
+ impacto = Column(String, nullable=False)
65
+ dimensao = Column(String, nullable=False)
66
+
67
+ observacoes = Column(String)
68
+
69
+ # Dia de inclusão
70
+ dia_inclusao = Column(String, nullable=True, index=True)
71
+
72
+ # Auditoria
73
+ data_hora_input = Column(DateTime, default=datetime.utcnow, index=True)
74
+
75
+
76
+ # =====================================================
77
+ # TABELA FPSOS
78
+ # =====================================================
79
+ class FPSO(Base):
80
+ __tablename__ = "fpsos"
81
+
82
+ id = Column(Integer, primary_key=True)
83
+ nome = Column(String, unique=True, nullable=False, index=True)
84
+ ativo = Column(Boolean, default=True)
85
+ data_cadastro = Column(DateTime, default=datetime.utcnow)
86
+
87
+
88
+ # =====================================================
89
+ # LOG DE AUDITORIA (OFICIAL)
90
+ # =====================================================
91
+ class LogAcesso(Base):
92
+ __tablename__ = "log_acesso"
93
+
94
+ id = Column(Integer, primary_key=True)
95
+
96
+ usuario = Column(String, nullable=False, index=True)
97
+ acao = Column(String, nullable=False)
98
+ tabela = Column(String, nullable=True)
99
+ registro_id = Column(Integer, nullable=True)
100
+
101
+ data_hora = Column(DateTime, default=datetime.utcnow, index=True)
102
+
103
+
104
+ # =====================================================
105
+ # USUÁRIOS
106
+ # =====================================================
107
+ class Usuario(Base):
108
+ __tablename__ = "usuarios"
109
+
110
+ id = Column(Integer, primary_key=True)
111
+
112
+ # login & segurança
113
+ usuario = Column(String, unique=True, nullable=False, index=True)
114
+ senha = Column(String, nullable=False)
115
+
116
+ # perfil & status
117
+ perfil = Column(String, nullable=False) # admin | usuario | consulta
118
+ ativo = Column(Boolean, default=True)
119
+
120
+ # auditoria
121
+ data_criacao = Column(DateTime, default=datetime.utcnow)
122
+
123
+ # UI/contato
124
+ nome = Column(String, nullable=True, index=True)
125
+ email = Column(String, unique=True, index=True, nullable=True)
126
+
127
+ # aniversário
128
+ data_aniversario = Column(Date, nullable=True)
129
+
130
+
131
+ # =====================================================
132
+ # QUIZ - PERGUNTAS
133
+ # =====================================================
134
+ class QuizPergunta(Base):
135
+ __tablename__ = "quiz_perguntas"
136
+
137
+ id = Column(Integer, primary_key=True)
138
+ pergunta = Column(String, nullable=False)
139
+ ativo = Column(Boolean, default=True)
140
+ data_criacao = Column(DateTime, default=datetime.utcnow)
141
+
142
+ respostas = relationship(
143
+ "QuizResposta",
144
+ back_populates="pergunta",
145
+ cascade="all, delete-orphan"
146
+ )
147
+
148
+
149
+ # =====================================================
150
+ # QUIZ - RESPOSTAS
151
+ # =====================================================
152
+ class QuizResposta(Base):
153
+ __tablename__ = "quiz_respostas"
154
+
155
+ id = Column(Integer, primary_key=True)
156
+ pergunta_id = Column(Integer, ForeignKey("quiz_perguntas.id"), nullable=False)
157
+ texto = Column(String, nullable=False)
158
+ correta = Column(Boolean, default=False)
159
+
160
+ pergunta = relationship("QuizPergunta", back_populates="respostas")
161
+
162
+
163
+ # =====================================================
164
+ # QUIZ - PONTUAÇÃO / RANKING
165
+ # =====================================================
166
+ class QuizPontuacao(Base):
167
+ __tablename__ = "quiz_pontuacoes"
168
+
169
+ id = Column(Integer, primary_key=True)
170
+ usuario = Column(String, nullable=False, index=True)
171
+ pontos = Column(Integer, nullable=False, default=0)
172
+ data = Column(DateTime, default=datetime.utcnow, index=True)
173
+
174
+
175
+ # =====================================================
176
+ # VÍDEOS - CATEGORIAS
177
+ # =====================================================
178
+ class VideoCategoria(Base):
179
+ __tablename__ = "video_categorias"
180
+
181
+ id = Column(Integer, primary_key=True)
182
+ nome = Column(String, nullable=False, unique=True)
183
+ ativo = Column(Boolean, default=True)
184
+ data_criacao = Column(DateTime, default=datetime.utcnow)
185
+
186
+
187
+ # =====================================================
188
+ # VÍDEOS
189
+ # =====================================================
190
+ class Video(Base):
191
+ __tablename__ = "videos"
192
+
193
+ id = Column(Integer, primary_key=True)
194
+ titulo = Column(String, nullable=False)
195
+ descricao = Column(String)
196
+ url = Column(String, nullable=False)
197
+
198
+ categoria_id = Column(Integer, ForeignKey("video_categorias.id"))
199
+ categoria = relationship("VideoCategoria")
200
+
201
+ ativo = Column(Boolean, default=True)
202
+ data_criacao = Column(DateTime, default=datetime.utcnow)
203
+
204
+
205
+ # =====================================================
206
+ # CALENDÁRIO - EVENTOS / LEMBRETES
207
+ # =====================================================
208
+ class EventoCalendario(Base):
209
+ __tablename__ = "eventos_calendario"
210
+
211
+ id = Column(Integer, primary_key=True)
212
+ titulo = Column(String, nullable=False)
213
+ descricao = Column(String)
214
+ data_evento = Column(Date, nullable=False)
215
+ data_lembrete = Column(Date)
216
+ ativo = Column(Boolean, default=True)
217
+ usuario_criacao = Column(String, nullable=False)
218
+ data_criacao = Column(DateTime, default=datetime.utcnow)
219
+
220
+
221
+ # =====================================================
222
+ # IOI-RUN - SUGESTÕES DO SISTEMA
223
+ # =====================================================
224
+ class IOIRunSugestao(Base):
225
+ __tablename__ = "ioirun_sugestao"
226
+
227
+ id = Column(Integer, primary_key=True, index=True)
228
+
229
+ # Identificação do autor
230
+ usuario = Column(String, nullable=False, index=True)
231
+
232
+ # Conteúdo
233
+ area = Column(String, nullable=True, index=True)
234
+ mensagem = Column(Text, nullable=False)
235
+
236
+ # Resposta do time (admin)
237
+ resposta = Column(Text, nullable=True)
238
+ status = Column(String, default="pendente", nullable=False, index=True) # pendente | respondida
239
+ responsavel = Column(String, nullable=True)
240
+
241
+ # Auditoria
242
+ data_envio = Column(DateTime, default=datetime.utcnow, nullable=False, index=True)
243
+ data_resposta = Column(DateTime, nullable=True)
244
+
245
+
246
+ # =====================================================
247
+ # RNC - REGISTRO DE NÃO CONFORMIDADES (FOR-SGQ-08)
248
+ # =====================================================
249
+ class RNC(Base):
250
+ __tablename__ = "rnc"
251
+
252
+ id = Column(Integer, primary_key=True, index=True)
253
+
254
+ # Identificação
255
+ codigo = Column(String(20), unique=True, index=True) # ex.: RNC-2026-0001
256
+ titulo = Column(String(200), nullable=False)
257
+ descricao = Column(Text, nullable=False)
258
+
259
+ # Cabeçalho do formulário
260
+ data_form = Column(Date, nullable=True, index=True) # Data do formulário
261
+ emitente = Column(String(120), nullable=True, index=True)
262
+ rnc_cliente_numero = Column(String(50), nullable=True)
263
+ cliente_emitente = Column(String(120), nullable=True)
264
+ area_solicitante = Column(String(120), nullable=True, index=True)
265
+ area_notificada = Column(String(120), nullable=True, index=True)
266
+ origem = Column(String(50), nullable=True, index=True) # Auditoria Interna/Externa/Outras
267
+
268
+ # Envolvidos
269
+ envolvido1_nome = Column(String(120), nullable=True)
270
+ envolvido1_matricula = Column(String(50), nullable=True)
271
+ envolvido1_funcao = Column(String(120), nullable=True)
272
+ envolvido2_nome = Column(String(120), nullable=True)
273
+ envolvido2_matricula = Column(String(50), nullable=True)
274
+ envolvido2_funcao = Column(String(120), nullable=True)
275
+
276
+ # Classificação
277
+ tipo = Column(String(50), nullable=True)
278
+ severidade = Column(String(20), nullable=True)
279
+ prioridade = Column(String(20), nullable=True)
280
+
281
+ # Status e prazos
282
+ status = Column(String(30), default="Aberta", nullable=False, index=True)
283
+ data_abertura = Column(DateTime, default=datetime.utcnow, index=True)
284
+ prazo = Column(DateTime, nullable=True, index=True)
285
+ encerrada_em = Column(DateTime, nullable=True, index=True)
286
+
287
+ # Responsáveis
288
+ responsavel = Column(String(120), nullable=True, index=True)
289
+ criado_por = Column(String(120), nullable=False, index=True)
290
+
291
+ # Complementares
292
+ cliente = Column(String(120), nullable=True)
293
+ local = Column(String(120), nullable=True)
294
+
295
+ # Análise das causas
296
+ metodologia = Column(String(120), nullable=True) # ex.: Ishikawa, 5 Porquês
297
+ causa_raiz = Column(Text, nullable=True) # descrição da causa raiz
298
+ ishikawa_json = Column(Text, nullable=True) # opcional: armazenar estrutura Ishikawa em JSON
299
+
300
+ # Auditoria
301
+ data_hora_input = Column(DateTime, default=datetime.utcnow, index=True)
302
+
303
+ # Relacionamentos
304
+ comentarios = relationship("RNCComentario", back_populates="rnc", cascade="all, delete-orphan")
305
+ acoes = relationship("RNCAcaoCorretiva", back_populates="rnc", cascade="all, delete-orphan")
306
+ anexos = relationship("RNCAnexo", back_populates="rnc", cascade="all, delete-orphan")
307
+
308
+
309
+ # =====================================================
310
+ # RNC - COMENTÁRIOS / TIMELINE
311
+ # =====================================================
312
+ class RNCComentario(Base):
313
+ __tablename__ = "rnc_comentario"
314
+
315
+ id = Column(Integer, primary_key=True, index=True)
316
+ rnc_id = Column(Integer, ForeignKey("rnc.id"), nullable=False, index=True)
317
+
318
+ data = Column(DateTime, default=datetime.utcnow, index=True)
319
+ autor = Column(String(120), nullable=False, index=True)
320
+ mensagem = Column(Text, nullable=False)
321
+
322
+ status_novo = Column(String(30), nullable=True, index=True)
323
+ prazo_novo = Column(DateTime, nullable=True, index=True)
324
+ responsavel_novo = Column(String(120), nullable=True, index=True)
325
+
326
+ rnc = relationship("RNC", back_populates="comentarios")
327
+
328
+
329
+ # =====================================================
330
+ # RNC - AÇÕES CORRETIVAS / PREVENTIVAS
331
+ # =====================================================
332
+ class RNCAcaoCorretiva(Base):
333
+ __tablename__ = "rnc_acao"
334
+
335
+ id = Column(Integer, primary_key=True, index=True)
336
+ rnc_id = Column(Integer, ForeignKey("rnc.id"), nullable=False, index=True)
337
+
338
+ descricao = Column(Text, nullable=False)
339
+ responsavel = Column(String(120), nullable=True, index=True)
340
+ prazo = Column(DateTime, nullable=True, index=True)
341
+
342
+ status = Column(String(30), default="Planejada", nullable=False, index=True)
343
+ eficacia = Column(String(30), nullable=True, index=True)
344
+ conclusao_em = Column(DateTime, nullable=True, index=True)
345
+
346
+ rnc = relationship("RNC", back_populates="acoes")
347
+
348
+
349
+ # =====================================================
350
+ # RNC - ANEXOS
351
+ # =====================================================
352
+ class RNCAnexo(Base):
353
+ __tablename__ = "rnc_anexo"
354
+
355
+ id = Column(Integer, primary_key=True, index=True)
356
+ rnc_id = Column(Integer, ForeignKey("rnc.id"), nullable=False, index=True)
357
+
358
+ nome_arquivo = Column(String(255), nullable=False)
359
+ caminho = Column(String(500), nullable=False)
360
+ conteudo_tipo = Column(String(120), nullable=True)
361
+
362
+ enviado_por = Column(String(120), nullable=True, index=True)
363
+ enviado_em = Column(DateTime, default=datetime.utcnow, index=True)
364
+
365
+ rnc = relationship("RNC", back_populates="anexos")
366
+
367
+
368
+ # =====================================================
369
+ # AVISO GLOBAL (banner superior)
370
+ # =====================================================
371
+ class AvisoGlobal(Base):
372
+ __tablename__ = "aviso_global"
373
+
374
+ id = Column(Integer, primary_key=True, index=True)
375
+ mensagem = Column(Text, nullable=False)
376
+
377
+ # Estilo/visual
378
+ bg_color = Column(String(32), default="#FFF3CD")
379
+ text_color = Column(String(32), default="#664D03")
380
+ largura = Column(String(16), default="100%")
381
+ efeito = Column(String(16), default="marquee")
382
+ velocidade = Column(Integer, default=20)
383
+ font_size = Column(Integer, default=14)
384
+
385
+ # Controle/estado
386
+ ativo = Column(Boolean, default=True, index=True)
387
+
388
+ # Auditoria
389
+ created_at = Column(DateTime(timezone=True), server_default=func.now())
390
+ updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())
391
+
392
+
393
+ # =====================================================
394
+ # RECEBIMENTO — PLANILHA OFICIAL (UM REGISTRO POR LINHA)
395
+ # Alinhado ao layout oficial de 37 colunas
396
+ # =====================================================
397
+ class RecebimentoRegistro(Base):
398
+ __tablename__ = "recebimento_registros"
399
+
400
+ # PK interno + ID da planilha
401
+ id = Column(Integer, primary_key=True, autoincrement=True, index=True)
402
+ id_planilha = Column(Integer, nullable=True, index=True, unique=True) # ID da planilha, se houver
403
+
404
+ # Datas
405
+ data = Column(Date, nullable=True)
406
+ data_emissao = Column(Date, nullable=True)
407
+
408
+ # Horas (texto HH:MM:SS para compatibilidade)
409
+ hora_chegada_portaria = Column(String(8), nullable=True)
410
+ hora_chegada_ifs = Column(String(8), nullable=True)
411
+ hora_saida_ifs_wms = Column(String(8), nullable=True)
412
+ hora_liberacao_operacao = Column(String(8), nullable=True)
413
+ hora_chegada_operacao = Column(String(8), nullable=True)
414
+ hora_saida_operacao = Column(String(8), nullable=True)
415
+ hora_retorno_operacao = Column(String(8), nullable=True)
416
+ hora_liberacao_motorista = Column(String(8), nullable=True)
417
+
418
+ # Dados principais
419
+ placa_veiculo = Column(String(50), nullable=True)
420
+ transportadora = Column(String(255), nullable=True)
421
+ po = Column(String(60), nullable=True)
422
+ incoterms = Column(String(30), nullable=True)
423
+ qtd_sku = Column(Integer, nullable=True)
424
+ nota_fiscal = Column(String(80), nullable=True)
425
+ fornecedor = Column(String(255), nullable=True)
426
+
427
+ # Bools (SIM/NÃO/N/A)
428
+ quimicos = Column(Boolean, nullable=True)
429
+ fds = Column(Boolean, nullable=True)
430
+ repetro = Column(Boolean, nullable=True)
431
+ aprovado = Column(Boolean, nullable=True)
432
+
433
+ # Status/Texto
434
+ natureza_operacao = Column(String(120), nullable=True)
435
+ tipo_operacao = Column(String(120), nullable=True)
436
+ barco = Column(String(80), nullable=True)
437
+
438
+ # Campo alinhado com a coluna "DIVERGENCIA" do layout oficial
439
+ divergencia = Column(String(200), nullable=True)
440
+
441
+ ifs = Column(String(120), nullable=True)
442
+ wms = Column(String(120), nullable=True)
443
+ fotografia = Column(String(255), nullable=True)
444
+ entrega = Column(String(120), nullable=True)
445
+ projeto = Column(String(120), nullable=True)
446
+ good_receipt = Column(String(120), nullable=True)
447
+ divergencia_recebimento = Column(String(255), nullable=True)
448
+ qualidade = Column(String(120), nullable=True)
449
+ divergencia_qualidade = Column(String(255), nullable=True)
450
+ observacao = Column(Text, nullable=True)
451
+ agendamento = Column(String(120), nullable=True)
452
+ responsavel = Column(String(120), nullable=True)
453
+
454
+ # Novos campos (opcionais) para colunas adicionais do layout
455
+ po_alt = Column(String(60), nullable=True) # mapeia "P.O" (alternativo)
456
+ pn = Column(String(120), nullable=True) # mapeia "PN"
457
+ lot_batch = Column(String(120), nullable=True) # mapeia "LOT BATCH"
458
+
459
+ # Auditoria mínima
460
+ created_by = Column(String(150), nullable=True)
461
+ updated_by = Column(String(150), nullable=True)
462
+ created_at = Column(DateTime, default=datetime.utcnow)
463
+ updated_at = Column(DateTime, default=datetime.utcnow, onupdate=datetime.utcnow)
module_loader.py ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ module_loader.py — Carregador dinâmico de módulos por ambiente (TESTE/Produção)
5
+ Permite importar 'operacao', 'consulta', etc. a partir de um base_path escolhido no login.
6
+ """
7
+
8
+ import os
9
+ import importlib.util
10
+ import types
11
+ import streamlit as st
12
+
13
+ SESSION_ENV_BASE_KEY = "__env_base_path__"
14
+ SESSION_MOD_CACHE_KEY = "__env_module_cache__"
15
+
16
+ def set_env_base(base_path: str):
17
+ """Define o caminho base do ambiente (ex.: pasta de produção ou teste)."""
18
+ base_path = os.path.abspath(base_path)
19
+ if not os.path.isdir(base_path):
20
+ raise RuntimeError(f"Caminho do ambiente inválido: {base_path}")
21
+ st.session_state[SESSION_ENV_BASE_KEY] = base_path
22
+ # Zera cache ao trocar ambiente
23
+ st.session_state[SESSION_MOD_CACHE_KEY] = {}
24
+
25
+ def get_env_base() -> str:
26
+ base = st.session_state.get(SESSION_ENV_BASE_KEY)
27
+ if not base:
28
+ # Default: usar a própria pasta onde está o app (Produção)
29
+ base = os.path.abspath(os.getcwd())
30
+ st.session_state[SESSION_ENV_BASE_KEY] = base
31
+ st.session_state[SESSION_MOD_CACHE_KEY] = {}
32
+ return base
33
+
34
+ def _mod_cache() -> dict:
35
+ cache = st.session_state.get(SESSION_MOD_CACHE_KEY)
36
+ if cache is None:
37
+ cache = {}
38
+ st.session_state[SESSION_MOD_CACHE_KEY] = cache
39
+ return cache
40
+
41
+ def load_module(module_name: str) -> types.ModuleType:
42
+ """
43
+ Carrega um módulo pelo nome (ex.: 'operacao') a partir do base_path atual.
44
+ Mantém cache por ambiente/arquivo para evitar reimportações.
45
+ """
46
+ base_path = get_env_base()
47
+ mod_path = os.path.join(base_path, f"{module_name}.py")
48
+ if not os.path.isfile(mod_path):
49
+ # fallback: se não achar .py direto, permita subpastas (ex.: envs/test/modulos/operacao.py)
50
+ # ajuste conforme sua estrutura real:
51
+ mod_path_alt = os.path.join(base_path, "modulos", f"{module_name}.py")
52
+ if os.path.isfile(mod_path_alt):
53
+ mod_path = mod_path_alt
54
+ else:
55
+ raise FileNotFoundError(f"Módulo '{module_name}' não encontrado em {base_path}")
56
+
57
+ cache_key = f"{base_path}::{module_name}"
58
+ cache = _mod_cache()
59
+ mod = cache.get(cache_key)
60
+ if mod:
61
+ return mod
62
+
63
+ spec = importlib.util.spec_from_file_location(module_name, mod_path)
64
+ mod = importlib.util.module_from_spec(spec)
65
+ assert spec and spec.loader, f"Falha ao preparar spec para {mod_path}"
66
+ spec.loader.exec_module(mod)
67
+ cache[cache_key] = mod
68
+ return mod
modules_map.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # modules_map.py
3
+ from calendario import main as calendario
4
+ from calendario_mensal import main as calendario_mensal
5
+
6
+ MODULES = {
7
+ # =============================
8
+ # Grupo: Operação Load
9
+ # =============================
10
+ "formulario": {
11
+ "key": "formulario",
12
+ "label": "Formulário",
13
+ "descricao": "Cadastro de dados operacionais",
14
+ "perfis": ["admin", "usuario"],
15
+ "grupo": "Operação Load"
16
+ },
17
+ "consulta": {
18
+ "key": "consulta",
19
+ "label": "Consulta",
20
+ "descricao": "Consulta e exportação de registros",
21
+ "perfis": ["admin", "usuario"],
22
+ "grupo": "Operação Load"
23
+ },
24
+ "relatorio": {
25
+ "key": "relatorio",
26
+ "label": "Relatório",
27
+ "descricao": "Indicadores e análises",
28
+ "perfis": ["admin"],
29
+ "grupo": "Operação Load"
30
+ },
31
+ "ranking": {
32
+ "key": "ranking",
33
+ "label": "Ranking",
34
+ "descricao": "Classificação do quiz",
35
+ "perfis": ["admin", "usuario"],
36
+ "grupo": "Operação Load"
37
+ },
38
+ "quiz": {
39
+ "key": "quiz",
40
+ "label": "Quiz",
41
+ "descricao": "Questionário de conhecimentos",
42
+ "perfis": ["admin", "usuario"],
43
+ "grupo": "Operação Load"
44
+ },
45
+ "quiz_admin": {
46
+ "key": "quiz_admin",
47
+ "label": "Quiz Admin",
48
+ "descricao": "Gestão de perguntas do quiz",
49
+ "perfis": ["admin"],
50
+ "grupo": "Operação Load"
51
+ },
52
+ "videos": {
53
+ "key": "videos",
54
+ "label": "Vídeos",
55
+ "descricao": "Biblioteca de vídeos",
56
+ "perfis": ["admin", "usuario"],
57
+ "grupo": "Operação Load"
58
+ },
59
+ "usuarios": {
60
+ "key": "usuarios",
61
+ "label": "Usuários",
62
+ "descricao": "Gestão de usuários",
63
+ "perfis": ["admin"],
64
+ "grupo": "Operação Load"
65
+ },
66
+ "administracao": {
67
+ "key": "administracao",
68
+ "label": "Administração",
69
+ "descricao": "Administração do sistema",
70
+ "perfis": ["admin", "usuario"],
71
+ "grupo": "Operação Load"
72
+ },
73
+ "auditoria": {
74
+ "key": "auditoria",
75
+ "label": "Auditoria",
76
+ "descricao": "Log de ações do sistema",
77
+ "perfis": ["admin"],
78
+ "grupo": "Operação Load"
79
+ },
80
+ "jogos": {
81
+ "key": "jogos",
82
+ "label": "Jogos",
83
+ "descricao": "Mini-games e diversão",
84
+ "perfis": ["admin"],
85
+ "grupo": "Operação Load"
86
+ },
87
+ "calendario": {
88
+ "key": "calendario",
89
+ "label": "Calendário",
90
+ "descricao": "Calendário de eventos",
91
+ "perfis": ["admin", "consulta", "usuario"],
92
+ "grupo": "Operação Load"
93
+ },
94
+ "calendario_mensal": {
95
+ "key": "calendario_mensal",
96
+ "label": "Calendário Mensal",
97
+ "descricao": "Calendário de eventos mensal",
98
+ "perfis": ["admin", "consulta", "usuario"],
99
+ "grupo": "Operação Load"
100
+ },
101
+ "auditoria_cleanup": {
102
+ "key": "auditoria_cleanup",
103
+ "label": "Limpeza Auditoria",
104
+ "descricao": "Exclusão de logs antigos de auditoria",
105
+ "perfis": ["admin"],
106
+ "grupo": "Operação Load"
107
+ },
108
+ "importacao": {
109
+ "key": "importacao",
110
+ "label": "Importação",
111
+ "descricao": "Importação de dados via Excel",
112
+ "perfis": ["admin", "usuario"],
113
+ "grupo": "Operação Load"
114
+ },
115
+ "db_admin": {
116
+ "key": "db_admin",
117
+ "label": "Admin DB (Schema)",
118
+ "descricao": "Editar/Excluir/Adicionar colunas e tipos de dados",
119
+ "perfis": ["admin"],
120
+ "grupo": "Operação Load"
121
+ },
122
+ "db_monitor": {
123
+ "key": "db_monitor",
124
+ "label": "Monitor DB",
125
+ "descricao": "Estatísticas, ocupação e backup planejado do banco",
126
+ "perfis": ["admin"],
127
+ "grupo": "Operação Load"
128
+ },
129
+ "db_export_import": {
130
+ "key": "db_export_import",
131
+ "label": "Exportação/Importação DB",
132
+ "descricao": "Export/Import de todas as tabelas do banco ativo",
133
+ "perfis": ["admin"],
134
+ "grupo": "Operação Load"
135
+ },
136
+ "produtividade_especialista": {
137
+ "key": "produtividade_especialista",
138
+ "label": "Produtividade por Especialista",
139
+ "descricao": "Relatório de produtividade do especialista",
140
+ "perfis": ["admin", "usuario"],
141
+ "grupo": "Operação Load"
142
+ },
143
+ "outlook_relatorio": {
144
+ "key": "outlook_relatorio",
145
+ "label": "Relatório portaria",
146
+ "descricao": "Relatório de entrada e saída da portaria - ARM",
147
+ "perfis": ["admin", "usuario"],
148
+ "grupo": "Operação Load"
149
+ },
150
+ "repositorio_load": {
151
+ "key": "repositorio_load",
152
+ "label": "Repositório Load",
153
+ "descricao": "Upload (Admin) e Consulta/Download (Usuário) de Excel e PDF",
154
+ "perfis": ["admin", "usuario"],
155
+ "grupo": "Operação Load"
156
+ },
157
+
158
+ # =============================
159
+ # Grupo: Backload
160
+ # =============================
161
+ "backload_consulta": {
162
+ "key": "backload_consulta",
163
+ "label": "Consulta Backload",
164
+ "descricao": "Consulta de operações Backload",
165
+ "perfis": ["admin", "usuario"],
166
+ "grupo": "Backload"
167
+ },
168
+
169
+ # =============================
170
+ # Grupo: Operação
171
+ # =============================
172
+ "operacao": {
173
+ "key": "operacao",
174
+ "label": "Operação",
175
+ "descricao": "Relatórios via API (Mayasuite)",
176
+ "perfis": ["admin"],
177
+ "grupo": "Operação"
178
+ },
179
+
180
+ # =============================
181
+ # Grupo: Indicadores
182
+ # =============================
183
+ "indicadores": {
184
+ "key": "indicadores",
185
+ "label": "Indicadores",
186
+ "descricao": "Relatórios de indicadores (KPIs por API)",
187
+ "perfis": ["admin"],
188
+ "grupo": "BI / Indicadores"
189
+ },
190
+
191
+ # =============================
192
+ # Grupo: Terceiros
193
+ # =============================
194
+ "terceiros_gestao": {
195
+ "key": "terceiros_gestao",
196
+ "label": "Gestão Terceiros",
197
+ "descricao": "Controle de fornecedores e terceiros",
198
+ "perfis": ["admin"],
199
+ "grupo": "Terceiros"
200
+ },
201
+
202
+ # =============================
203
+ # Grupo: IOI-RUN
204
+ # =============================
205
+ "resposta": {
206
+ "key": "resposta",
207
+ "label": "Resposta de perguntas",
208
+ "descricao": "Resposta de perguntas do sistema IOI‑RUN",
209
+ "perfis": ["admin"],
210
+ "grupo": "Resposta de perguntas"
211
+ },
212
+ "sugestoes_ioirun": {
213
+ "key": "sugestoes_ioirun",
214
+ "label": "Sugestões IOI‑RUN",
215
+ "descricao": "Envio e histórico de sugestões do sistema IOI‑RUN",
216
+ "perfis": ["admin", "usuario", "consulta"],
217
+ "grupo": "Geral"
218
+ },
219
+
220
+ # =============================
221
+ # Grupo: Qualidade
222
+ # =============================
223
+ "rnc": {
224
+ "key": "rnc",
225
+ "label": "RNC • Não Conformidades",
226
+ "descricao": "Registro e acompanhamento de não conformidades, plano de ação e anexos.",
227
+ "perfis": ["admin", "usuario"],
228
+ "grupo": "Sistemas de Gestão da Qualidade"
229
+ },
230
+ "rnc_listagem": {
231
+ "key": "rnc_listagem",
232
+ "label": "RNC • Listagem",
233
+ "descricao": "Consulta de RNCs com filtros, exportação e expanders",
234
+ "perfis": ["admin", "usuario", "consulta"],
235
+ "grupo": "Sistemas de Gestão da Qualidade"
236
+ },
237
+ "rnc_relatorio": {
238
+ "key": "rnc_relatorio",
239
+ "label": "RNC • Relatórios",
240
+ "descricao": "Painel analítico completo de RNC",
241
+ "perfis": ["admin", "usuario", "consulta"],
242
+ "grupo": "Sistemas de Gestão da Qualidade"
243
+ },
244
+ "repo_rnc": {
245
+ "key": "repo_rnc",
246
+ "label": "RNC • Repositório",
247
+ "descricao": "Upload/Download de documentos e planilhas das RNCs",
248
+ "perfis": ["admin", "usuario"],
249
+ "grupo": "Sistemas de Gestão da Qualidade"
250
+ },
251
+
252
+ # ======================================================
253
+ # RECEBIMENTO — PLANILHA OFICIAL
254
+ # ======================================================
255
+ "recebimento": {
256
+ "key": "recebimento",
257
+ "label": "Recebimento",
258
+ "descricao": "Recebimento – Planilha Oficial (importação, edição e controle de registros)",
259
+ "perfis": ["admin", "usuario"],
260
+ "grupo": "Operação"
261
+ },
262
+ }
263
+
operacao.py ADDED
@@ -0,0 +1,1564 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ """
4
+ operacao.py — Módulo Operação (Mayasuite)
5
+
6
+ Recursos principais:
7
+ - Modo rápido (Consulta leve): apenas 1 página + colunas essenciais na visualização
8
+ - Limite de itens por página (via x-filter-limit se suportado pela API)
9
+ - Cache de consultas (TTL configurável) + botões para limpar cache
10
+ - Botão de cancelar consulta
11
+ - Barra de progresso por página + status
12
+ - Debounce no submit
13
+ - Retentativas para 429/5xx (incl. 502) e timeouts com backoff
14
+ - Filtros avançados: Data operação (única), Type(API), Tipo SBM (relacionado ao Depositante),
15
+ Endereços/Notas/Categorias com sugestões, Destinatários (multisseleção) + CNPJs múltiplos,
16
+ Depositantes (multisseleção)
17
+ - Formatação PT-BR: datas DD/MM/AAAA, valores R$, SKU sem zeros à esquerda, oculta colunas vazias
18
+ - KPIs dinâmicos por consulta (cards) — Estoque, Endereços, NF Entrada/Saída, etc.
19
+ - Oculta navegação de outros módulos ao carregar
20
+ """
21
+
22
+ import os
23
+ import re
24
+ import json
25
+ import time
26
+ import random
27
+ from io import BytesIO
28
+ from datetime import datetime
29
+ import base64
30
+
31
+ import streamlit as st
32
+ import pandas as pd
33
+ import requests
34
+ from requests.adapters import HTTPAdapter
35
+ from urllib3.util.retry import Retry
36
+
37
+ # Se existirem, importe utilitários do seu projeto
38
+ try:
39
+ from utils_permissoes import verificar_permissao
40
+ except Exception:
41
+ def verificar_permissao(_):
42
+ return True
43
+
44
+ try:
45
+ from utils_auditoria import registrar_log
46
+ except Exception:
47
+ def registrar_log(**kwargs):
48
+ return None
49
+
50
+ # =====================================================
51
+ # CONFIG (.env)
52
+ # =====================================================
53
+ OP_API_BASE_URL = os.getenv("OP_API_BASE_URL", "https://api.mayasuite.com").rstrip("/")
54
+ OP_LOGIN_EMAIL = (os.getenv("OP_LOGIN_EMAIL", "") or "").strip()
55
+ OP_LOGIN_PASSWORD = (os.getenv("OP_LOGIN_PASSWORD", "") or "").strip()
56
+
57
+ # Timeouts
58
+ OP_CONNECT_TIMEOUT = float((os.getenv("OP_CONNECT_TIMEOUT", "10") or "10")) # s
59
+ OP_READ_TIMEOUT = float((os.getenv("OP_READ_TIMEOUT", "90") or "90")) # s
60
+
61
+ # Token direto (bypass do login)
62
+ OP_ACCESS_TOKEN = (os.getenv("OP_ACCESS_TOKEN", "") or "").strip()
63
+
64
+ # Diagnóstico / alternativas
65
+ OP_LOGIN_EMAIL_ALT = (os.getenv("OP_LOGIN_EMAIL_ALT", "") or "").strip()
66
+ OP_LOGIN_PASSWORD_ALT = (os.getenv("OP_LOGIN_PASSWORD_ALT", "") or "").strip()
67
+ OP_LOGIN_DEBUG = (os.getenv("OP_LOGIN_DEBUG", "false") or "").strip().lower() == "true"
68
+
69
+ # Headers compatíveis
70
+ OP_COMPAT_HEADERS = (os.getenv("OP_COMPAT_HEADERS", "true") or "").strip().lower() == "true"
71
+
72
+ # Proxy (opcional)
73
+ OP_PROXY_HTTP = (os.getenv("OP_PROXY_HTTP", "") or "").strip()
74
+ OP_PROXY_HTTPS = (os.getenv("OP_PROXY_HTTPS", "") or "").strip()
75
+ PROXIES = {"http": OP_PROXY_HTTP, "https": OP_PROXY_HTTPS} if (OP_PROXY_HTTP or OP_PROXY_HTTPS) else None
76
+
77
+ # Rate limit / timeouts
78
+ OP_RATE_DELAY_SEC = float((os.getenv("OP_RATE_DELAY_SEC", "0.8") or "0.8"))
79
+ OP_MAX_RETRIES_PER_PAGE = int((os.getenv("OP_MAX_RETRIES_PER_PAGE", "3") or "3"))
80
+ OP_MAX_PAGES = int((os.getenv("OP_MAX_PAGES", "0") or "0"))
81
+ OP_MAX_TIMEOUT_RETRIES = int((os.getenv("OP_MAX_TIMEOUT_RETRIES", "2") or "2"))
82
+ OP_TIMEOUT_BACKOFF_BASE = float((os.getenv("OP_TIMEOUT_BACKOFF_BASE", "5") or "5"))
83
+
84
+ # Retentativas específicas para 5xx (inclusive 502)
85
+ OP_MAX_RETRIES_5XX = int((os.getenv("OP_MAX_RETRIES_5XX", "4") or "4"))
86
+ OP_5XX_BACKOFF_BASE = float((os.getenv("OP_5XX_BACKOFF_BASE", "3") or "3"))
87
+
88
+ # Tempo de cache (minutos) — configura no .env: CACHE_TTL_MIN=5
89
+ CACHE_TTL_MIN = int((os.getenv("CACHE_TTL_MIN", "5") or "5"))
90
+ CACHE_TTL_SEC = CACHE_TTL_MIN * 60
91
+
92
+ # Bases
93
+ BASES_MAP = {
94
+ "Matriz": "5a926346-15ee-4af4-ba2d-1a71d62d9b51",
95
+ "CL": "b0099983-5b44-4650-821a-e352c5c1f10e",
96
+ "YARD": "2c506e56-641d-48e2-a330-93fd088526cf",
97
+ }
98
+
99
+ # Endpoints
100
+ ENDPOINTS = {
101
+ "Nota Fiscal de Entrada": "/wsreceipt/list",
102
+ "Nota Fiscal de Saída": "/wsdispatch/list",
103
+ "Endereços": "/address/list",
104
+ "Endereços bloqueados": "/address/blocking/list",
105
+ "Lista de Pedido": "/cargorelease/list",
106
+ "Monitor Sefaz": "/monitor/nfe/list",
107
+ "Estoque": "/stock/list",
108
+ "Operações": "/operation/list",
109
+ "Agendamento": "/yms/scheduling/list",
110
+ "Produto": "/product/list",
111
+ "Faturamento": "/financial/invoice/list",
112
+ }
113
+
114
+ # Tipos (API) e Tipos SBM
115
+ OPERATION_TYPES = ["ALLOCATION", "CHECK", "DISPATCH", "MOVEMENT", "RECEIVING", "PICK"]
116
+ SBM_TYPES = ["SBM - LOAD", "SBM - BACKLOAD", "OUTROS"]
117
+
118
+ # Cancelamento
119
+ CANCEL_TOKEN_KEY = "__op_cancel__"
120
+
121
+ # ✅ NOVO: OAuth2 Client Credentials (se a API suportar)
122
+ OAUTH_TOKEN_URL = (os.getenv("OAUTH_TOKEN_URL", "") or "").strip() # ex.: https://api.mayasuite.com/oauth/token
123
+ OAUTH_CLIENT_ID = (os.getenv("CLIENT_ID", "") or "").strip()
124
+ OAUTH_CLIENT_SECRET = (os.getenv("CLIENT_SECRET", "") or "").strip()
125
+ OAUTH_SCOPE = (os.getenv("OAUTH_SCOPE", "") or "").strip() # opcional
126
+
127
+ # =====================================================
128
+ # RENAME_MAP — renomeia colunas por endpoint (somente se existirem)
129
+ # =====================================================
130
+ RENAME_MAP = {
131
+ "/wsreceipt/list": {
132
+ "wsreceipt_code": "NF Entrada",
133
+ "create_date": "Data Emissão",
134
+ "customer_document": "CNPJ_Depositante",
135
+ "customer_name": "Depositante",
136
+ "product_code": "SKU",
137
+ "product_description": "Descrição",
138
+ "qty": "Qtde",
139
+ "unit_measure_code": "Unidade",
140
+ "receipt_unit_value": "Vr. Unitário",
141
+ "receipt_value": "Vr. Total",
142
+ "lot": "Lote",
143
+ "sublot": "SubLote",
144
+ "expiration_date": "Validade",
145
+ "manufacturing_date": "Fabricação",
146
+ "location_id_code": "Endereço",
147
+ "last_update_date": "Última Atualização",
148
+ "last_update_user": "Usuário Atualização",
149
+ "category_description": "Categoria",
150
+ "type": "Tipo",
151
+ },
152
+ "/wsdispatch/list": {
153
+ "wsdispatch_code": "NF Saída",
154
+ "issue_date": "Data Emissão",
155
+ "recipient_document": "CNPJ_Destinatário",
156
+ "recipient_description": "Destinatário",
157
+ "product_code": "SKU",
158
+ "product_description": "Descrição",
159
+ "qty": "Qtde",
160
+ "unit_measure_code": "Unidade",
161
+ "item_unit_value": "Vr. Unitário",
162
+ "item_total_value": "Vr. Total",
163
+ "lot": "Lote",
164
+ "sublot": "SubLote",
165
+ "location_code": "Endereço",
166
+ "category_description": "Categoria",
167
+ "group_description": "Grupo",
168
+ "type": "Tipo",
169
+ },
170
+ "/address/list": {
171
+ "location_code": "Endereço",
172
+ "location_id_code": "Endereço",
173
+ "description": "Descrição",
174
+ "status": "Status",
175
+ "fpso": "FPSO",
176
+ "last_update_date": "Última Atualização",
177
+ "last_update_user": "Usuário Atualização",
178
+ "type": "Tipo",
179
+ },
180
+ "/address/blocking/list": {
181
+ "location_code": "Endereço",
182
+ "location_id_code": "Endereço",
183
+ "block_reason": "Motivo Bloqueio",
184
+ "block_date": "Data Bloqueio",
185
+ "block_user": "Usuário Bloqueio",
186
+ "unblock_date": "Data Desbloqueio",
187
+ "unblock_user": "Usuário Desbloqueio",
188
+ "status": "Status",
189
+ "type": "Tipo",
190
+ },
191
+ "/cargorelease/list": {
192
+ "cargorelease_code": "Pedido",
193
+ "create_date": "Data Criação",
194
+ "customer_document": "CNPJ_Depositante",
195
+ "customer_name": "Depositante",
196
+ "product_code": "SKU",
197
+ "product_description": "Descrição",
198
+ "qty": "Qtde",
199
+ "unit_measure_code": "Unidade",
200
+ "status": "Status",
201
+ "expiration_date": "Validade",
202
+ "manufacturing_date": "Fabricação",
203
+ "category_description": "Categoria",
204
+ "type": "Tipo",
205
+ },
206
+ "/monitor/nfe/list": {
207
+ "nfe_key": "Chave NFe",
208
+ "nfe_number": "Número NFe",
209
+ "status": "Status SEFAZ",
210
+ "protocol": "Protocolo",
211
+ "issue_date": "Data Emissão",
212
+ "recipient_document": "CNPJ_Destinatário",
213
+ "customer_document": "CNPJ_Depositante",
214
+ "message": "Mensagem",
215
+ "last_update_date": "Última Atualização",
216
+ "type": "Tipo",
217
+ },
218
+ "/stock/list": {
219
+ "date": "Data Operação",
220
+ "wsreceipt_code": "Nota Fiscal",
221
+ "product_code": "SKU",
222
+ "product_description": "Descrição",
223
+ "unit_measure_code": "Unidade",
224
+ "qty": "Qtde",
225
+ "item_unit_value": "Vr. Unitário",
226
+ "item_total_value": "Vr. Total",
227
+ "customer_document": "CNPJ_Depositante",
228
+ "customer_description": "Depositante",
229
+ "lot": "Lote",
230
+ "location_code": "Endereço",
231
+ "expiration_date": "Validade",
232
+ "manufacturing_date": "Fabricação",
233
+ "category_description": "Categoria",
234
+ "group_description": "Grupo",
235
+ "recipient_document": "CNPJ_Destinatário",
236
+ "recipient_description": "Destinatário",
237
+ "qty_reservation": "Qtde Reservada",
238
+ "type": "Tipo",
239
+ },
240
+ "/operation/list": {
241
+ "cargorelease_code": "Pedido",
242
+ "create_date": "Data",
243
+ "create_user": "Usuário Criação",
244
+ "customer_document": "CNPJ_Depositante",
245
+ "customer_name": "Depositante",
246
+ "expiration_date": "Validade",
247
+ "last_update_date": "Última Atualização",
248
+ "last_update_user": "Usuário Atualização",
249
+ "location_id_code": "Endereço",
250
+ "lot": "Lote",
251
+ "manufacturing_date": "Fabricação",
252
+ "product_code": "SKU",
253
+ "product_description": "Descrição",
254
+ "qty": "Qtde",
255
+ "receipt_unit_value": "Vr. Unitário",
256
+ "receipt_value": "Vr. Total",
257
+ "sublot": "SubLote",
258
+ "type": "Tipo",
259
+ "wsreceipt_code": "NF Entrada",
260
+ "category_description": "Categoria",
261
+ },
262
+ "/yms/scheduling/list": {
263
+ "scheduling_id": "ID Agendamento",
264
+ "yard": "Pátio",
265
+ "dock": "Doca",
266
+ "truck_plate": "Placa",
267
+ "driver_name": "Motorista",
268
+ "scheduled_date": "Data Agendada",
269
+ "scheduled_time": "Hora Agendada",
270
+ "status": "Status",
271
+ "last_update_date": "Última Atualização",
272
+ "type": "Tipo",
273
+ },
274
+ "/product/list": {
275
+ "product_code": "SKU",
276
+ "product_description": "Descrição",
277
+ "category_description": "Categoria",
278
+ "group_description": "Grupo",
279
+ "unit_measure_code": "Unidade",
280
+ "status": "Status",
281
+ "last_update_date": "Última Atualização",
282
+ "type": "Tipo",
283
+ },
284
+ "/financial/invoice/list": {
285
+ "invoice_number": "Número Fatura",
286
+ "invoice_date": "Data Fatura",
287
+ "customer_document": "CNPJ_Depositante",
288
+ "customer_name": "Depositante",
289
+ "total_value": "Vr. Total",
290
+ "status": "Status",
291
+ "nfe_key": "Chave NFe",
292
+ "wsdispatch_code": "NF Saída",
293
+ "last_update_date": "Última Atualização",
294
+ "type": "Tipo",
295
+ },
296
+ }
297
+
298
+ # =====================================================
299
+ # SESSÃO HTTP (retry 5xx/429) + PROGRESSO + CANCELAMENTO
300
+ # =====================================================
301
+ def _build_retry_adapter() -> HTTPAdapter:
302
+ """Adapter de retry: lida com 429/5xx transientes."""
303
+ retry = Retry(
304
+ total=3, connect=3, read=3, backoff_factor=1.0,
305
+ status_forcelist=[429, 500, 502, 503, 504],
306
+ allowed_methods=["POST"], raise_on_status=False,
307
+ )
308
+ return HTTPAdapter(max_retries=retry)
309
+
310
+ def _get_session() -> requests.Session:
311
+ sess = st.session_state.get("_op_session")
312
+ if sess is None:
313
+ sess = requests.Session()
314
+ adapter = _build_retry_adapter()
315
+ sess.mount("https://", adapter)
316
+ sess.mount("http://", adapter)
317
+ st.session_state["_op_session"] = sess
318
+ return sess
319
+
320
+ # =====================================================
321
+ # LOGIN / TOKEN
322
+ # =====================================================
323
+ class TokenManager:
324
+ """
325
+ Gerencia token de acesso:
326
+ - Se OP_ACCESS_TOKEN estiver definido no .env: usa diretamente (bypass).
327
+ - Se variáveis OAuth2 estiverem configuradas: obtém/renova via client_credentials.
328
+ - Caso contrário: usa login atual (POST /login) com OP_LOGIN_EMAIL/OP_LOGIN_PASSWORD
329
+ (inclui conta alternativa e cooldown).
330
+ """
331
+ def __init__(self):
332
+ self.access_token: str | None = OP_ACCESS_TOKEN if OP_ACCESS_TOKEN else None
333
+ self.expire_ts: float = 0.0
334
+ self._skew_sec: int = 30 # tolerância de relógio
335
+
336
+ def _fetch_oauth_token(self) -> tuple[str | None, float]:
337
+ """Tenta obter via OAuth2 client_credentials. Retorna (token, expire_ts_epoch)."""
338
+ if not (OAUTH_TOKEN_URL and OAUTH_CLIENT_ID and OAUTH_CLIENT_SECRET):
339
+ return None, 0.0
340
+ try:
341
+ resp = requests.post(
342
+ OAUTH_TOKEN_URL,
343
+ data={"grant_type": "client_credentials", "scope": OAUTH_SCOPE},
344
+ auth=(OAUTH_CLIENT_ID, OAUTH_CLIENT_SECRET),
345
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT),
346
+ proxies=PROXIES
347
+ )
348
+ if resp.status_code >= 400:
349
+ if OP_LOGIN_DEBUG:
350
+ st.warning(f"OAuth2 falhou ({resp.status_code}): {(resp.text or '')[:300]}")
351
+ return None, 0.0
352
+ data = resp.json()
353
+ token = data.get("access_token") or data.get("token")
354
+ expires_in = float(data.get("expires_in") or 3600.0)
355
+ return token, (time.time() + max(60.0, expires_in))
356
+ except requests.exceptions.RequestException as e:
357
+ if OP_LOGIN_DEBUG:
358
+ st.warning(f"Falha OAuth2: {e}")
359
+ return None, 0.0
360
+
361
+ def _fetch_login_token(self) -> tuple[str | None, float]:
362
+ """
363
+ Fallback para o login atual (POST /login), mantendo:
364
+ - alternativa (OP_LOGIN_EMAIL_ALT/OP_LOGIN_PASSWORD_ALT)
365
+ - cooldown por banimento (api_login_lock_until)
366
+ - e toda lógica de parsing/erros
367
+ """
368
+ lock_until = st.session_state.get("api_login_lock_until")
369
+ if lock_until and time.time() < lock_until:
370
+ raise RuntimeError("Login temporariamente bloqueado após 403 (cooldown ativo).")
371
+
372
+ url = f"{OP_API_BASE_URL}/login"
373
+ payload_str = json.dumps({"login": OP_LOGIN_EMAIL, "password": OP_LOGIN_PASSWORD}, ensure_ascii=False)
374
+ headers = {"Content-Type": "application/json"}
375
+
376
+ try:
377
+ resp = requests.post(url, headers=headers, data=payload_str,
378
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES)
379
+ except requests.exceptions.RequestException as e:
380
+ raise RuntimeError(f"Falha na conexão ao login: {e}")
381
+
382
+ body_text = (resp.text or "")[:600]
383
+ if resp.status_code >= 400:
384
+ if "banned" in body_text.lower():
385
+ st.session_state["api_login_lock_until"] = time.time() + 3600
386
+ if OP_LOGIN_DEBUG:
387
+ st.warning(f"Servidor retornou banimento. Cooldown 1h. Corpo: {body_text}")
388
+ raise RuntimeError("Origem/host/IP banido pelo servidor (403). Solicite desbloqueio/allowlist.")
389
+ if OP_LOGIN_EMAIL_ALT and OP_LOGIN_PASSWORD_ALT:
390
+ payload_alt = json.dumps({"login": OP_LOGIN_EMAIL_ALT, "password": OP_LOGIN_PASSWORD_ALT}, ensure_ascii=False)
391
+ resp2 = requests.post(url, headers=headers, data=payload_alt,
392
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES)
393
+ if resp2.status_code >= 400:
394
+ if OP_LOGIN_DEBUG:
395
+ st.warning(f"Alternativa falhou (HTTP {resp2.status_code}). Corpo: {(resp2.text or '')[:600]}")
396
+ raise RuntimeError(f"Login rejeitado ({resp.status_code}).")
397
+ try:
398
+ data2 = resp2.json()
399
+ except Exception:
400
+ raise RuntimeError("Resposta de login (alt) não é JSON.")
401
+ token2 = data2.get("access_token") or data2.get("token")
402
+ if not token2:
403
+ raise RuntimeError("Token (alt) não encontrado na resposta de login.")
404
+ return token2, (time.time() + 3600.0)
405
+ if OP_LOGIN_DEBUG:
406
+ st.warning(f"Login falhou (HTTP {resp.status_code}). Corpo: {body_text}")
407
+ raise RuntimeError(f"Login rejeitado ({resp.status_code}).")
408
+
409
+ try:
410
+ data = resp.json()
411
+ except Exception:
412
+ raise RuntimeError("Resposta de login não é JSON.")
413
+ token = data.get("access_token") or data.get("token") or (data if isinstance(data, str) else "")
414
+ if not token:
415
+ raise RuntimeError("Token 'access_token' não encontrado na resposta de login.")
416
+ return token, (time.time() + 3600.0)
417
+
418
+ def get_token(self) -> str:
419
+ """Obtém token atual (renova se necessário)."""
420
+ # Bypass: token fixo do .env
421
+ if OP_ACCESS_TOKEN:
422
+ self.access_token = OP_ACCESS_TOKEN
423
+ self.expire_ts = time.time() + 365*24*3600 # validade simbólica longa
424
+ return self.access_token
425
+
426
+ now = time.time()
427
+ if (not self.access_token) or (now + self._skew_sec >= self.expire_ts):
428
+ # Tenta primeiro OAuth2; se indisponível, usa login /login
429
+ token, exp_ts = self._fetch_oauth_token()
430
+ if not token:
431
+ token, exp_ts = self._fetch_login_token()
432
+ self.access_token = token
433
+ self.expire_ts = exp_ts or (now + 3600.0)
434
+ return self.access_token
435
+
436
+ def force_refresh(self) -> str:
437
+ """Força renovar o token (útil em 401) e retorna o novo."""
438
+ self.access_token = None
439
+ self.expire_ts = 0.0
440
+ return self.get_token()
441
+
442
+ # ✅ Instância global (mantém sua organização)
443
+ TM = TokenManager()
444
+
445
+ # 🛠️ Refatora função _get_token para delegar ao TokenManager
446
+ # (remove cache para não “congelar” o token; mantém assinatura/uso)
447
+ def _get_token(login_email: str, login_password: str) -> str:
448
+ """
449
+ Obtém token automaticamente:
450
+ - OAuth2 client_credentials se configurado;
451
+ - Caso contrário, login /login com e-mail/senha (mantendo sua lógica).
452
+ """
453
+ return TM.get_token()
454
+
455
+ # =====================================================
456
+ # HEADERS
457
+ # =====================================================
458
+ def _auth_headers(token: str, base_guid: str, page: int, limit: int, is_post: bool) -> dict:
459
+ h = {"Authorization": f"Bearer {token}", "Content-Type": "application/json", "x-user-enterprise-id": base_guid}
460
+ if OP_COMPAT_HEADERS:
461
+ h["Accept"] = "application/json"
462
+ h["User-Agent"] = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) ArmLoadApp/1.0 PowerQueryCompat"
463
+ h["Connection"] = "keep-alive"
464
+ h["Origin"] = OP_API_BASE_URL
465
+ h["Referer"] = OP_API_BASE_URL + "/"
466
+ # Paginação do servidor (se suportado)
467
+ h["x-filter-page"] = str(page)
468
+ h["x-filter-limit"] = str(limit)
469
+ return h
470
+
471
+ # =====================================================
472
+ # RATE LIMIT: helpers
473
+ # =====================================================
474
+ def _parse_retry_after(resp) -> float:
475
+ ra_hdr = resp.headers.get("Retry-After")
476
+ if ra_hdr:
477
+ try:
478
+ return float(ra_hdr.strip())
479
+ except Exception:
480
+ pass
481
+ try:
482
+ data = resp.json()
483
+ ra_body = data.get("retry-after") or ""
484
+ if isinstance(ra_body, (int, float)):
485
+ return float(ra_body)
486
+ if isinstance(ra_body, str):
487
+ m = re.search(r"(\d+)", ra_body)
488
+ if m:
489
+ return float(m.group(1))
490
+ except Exception:
491
+ pass
492
+ return 60.0
493
+
494
+ # =====================================================
495
+ # LIST — POST + RAW JSON com rate-limit/timeout/5xx
496
+ # + Progresso de carregamento + Cancelamento
497
+ # =====================================================
498
+ def _call_list_paginated(
499
+ path: str,
500
+ base_guid: str,
501
+ token: str,
502
+ body_filter: dict,
503
+ limit: int = 1000,
504
+ max_pages_override: int | None = None
505
+ ):
506
+ """
507
+ - POST com RAW JSON (data=payload_str), como Excel.
508
+ - Respeita 429 (Retry-After); aplica atraso entre páginas.
509
+ - Retenta automaticamente Timeout (backoff exponencial).
510
+ - Retenta 5xx (incl. 502) com backoff exponencial + jitter.
511
+ - Suporta cancelamento via st.session_state[CANCEL_TOKEN_KEY].
512
+ - Exibe barra de progresso e status de carregamento por página.
513
+ """
514
+ page = 1
515
+ all_rows = []
516
+ session = _get_session()
517
+ consecutive_429 = 0
518
+ max_pages = OP_MAX_PAGES if max_pages_override is None else max_pages_override
519
+
520
+ # ----- UI de progresso -----
521
+ progress_bar = st.session_state.get("__op_progress_bar__")
522
+ status_text = st.session_state.get("__op_status_text__")
523
+ if progress_bar is None:
524
+ progress_bar = st.progress(0)
525
+ st.session_state["__op_progress_bar__"] = progress_bar
526
+ if status_text is None:
527
+ status_text = st.empty()
528
+ st.session_state["__op_status_text__"] = status_text
529
+
530
+ denom = max_pages if (max_pages and max_pages > 0) else None
531
+
532
+ # reset progresso/cancelamento
533
+ st.session_state[CANCEL_TOKEN_KEY] = st.session_state.get(CANCEL_TOKEN_KEY, False)
534
+ st.session_state["op_pages_processed"] = 0
535
+
536
+ try:
537
+ while True:
538
+ # Cancelamento
539
+ if st.session_state.get(CANCEL_TOKEN_KEY):
540
+ raise RuntimeError("Consulta cancelada.")
541
+
542
+ if max_pages and page > max_pages:
543
+ break
544
+
545
+ url = f"{OP_API_BASE_URL}{path}"
546
+ headers = _auth_headers(token, base_guid, page, limit, is_post=True)
547
+ payload_str = json.dumps(body_filter or {}, ensure_ascii=False)
548
+
549
+ # ---- Retentativas por Timeout ----
550
+ timeout_attempt = 0
551
+ while True:
552
+ try:
553
+ resp = session.post(
554
+ url, headers=headers, data=payload_str,
555
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES
556
+ )
557
+ break
558
+ except (requests.exceptions.ReadTimeout,
559
+ requests.exceptions.ConnectTimeout,
560
+ requests.exceptions.Timeout) as e:
561
+ timeout_attempt += 1
562
+ if timeout_attempt > OP_MAX_TIMEOUT_RETRIES:
563
+ raise RuntimeError(f"Timeout após {OP_MAX_TIMEOUT_RETRIES} tentativas na página {page}: {e}")
564
+ sleep_sec = OP_TIMEOUT_BACKOFF_BASE * timeout_attempt
565
+ if OP_LOGIN_DEBUG:
566
+ st.warning(f"Timeout na página {page}. Retentativa {timeout_attempt} em ~{sleep_sec}s...")
567
+ time.sleep(sleep_sec)
568
+ except requests.exceptions.RequestException as e:
569
+ raise RuntimeError(f"Falha na página {page}: {e}")
570
+
571
+ # ✅ 401 → renovar token 1x (OAuth2/client_credentials ou /login) e re-tentar
572
+ if resp.status_code == 401:
573
+ token = TM.force_refresh()
574
+ headers = _auth_headers(token, base_guid, page, limit, is_post=True)
575
+ resp = session.post(
576
+ url, headers=headers, data=payload_str,
577
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES
578
+ )
579
+ if resp.status_code == 401:
580
+ raise RuntimeError(f"Token inválido/expirado após renovação (401) na página {page}: {resp.text[:500]}")
581
+
582
+ # 429 → Retry-After + repetir
583
+ if resp.status_code == 429:
584
+ wait_sec = _parse_retry_after(resp)
585
+ consecutive_429 += 1
586
+ if consecutive_429 > OP_MAX_RETRIES_PER_PAGE:
587
+ raise RuntimeError(
588
+ f"Limite de tentativas após 429 excedido na página {page}. Aguarde ~{wait_sec}s e tente novamente."
589
+ )
590
+ if OP_LOGIN_DEBUG:
591
+ st.warning(f"429 recebido. Aguardando ~{wait_sec}s antes de repetir página {page}...")
592
+ time.sleep(wait_sec)
593
+ resp = session.post(
594
+ url, headers=headers, data=payload_str,
595
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES
596
+ )
597
+
598
+ # 5xx (inclui 502 Bad Gateway) → backoff + jitter
599
+ if resp.status_code in (500, 502, 503, 504):
600
+ for attempt in range(1, OP_MAX_RETRIES_5XX + 1):
601
+ wait = OP_5XX_BACKOFF_BASE * (2 ** (attempt - 1)) + random.uniform(0, OP_5XX_BACKOFF_BASE)
602
+ if OP_LOGIN_DEBUG:
603
+ st.warning(f"{resp.status_code} recebido. Retentativa {attempt}/{OP_MAX_RETRIES_5XX} em ~{wait:.1f}s...")
604
+ time.sleep(wait)
605
+ try:
606
+ resp = session.post(
607
+ url, headers=headers, data=payload_str,
608
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES
609
+ )
610
+ except requests.exceptions.RequestException:
611
+ continue
612
+ if resp.status_code < 500:
613
+ break
614
+ if resp.status_code in (500, 502, 503, 504):
615
+ snippet = (resp.text or "")[:500]
616
+ raise RuntimeError(f"Erro {resp.status_code} na página {page}: {snippet}")
617
+
618
+ # Outros erros HTTP
619
+ if resp.status_code >= 400:
620
+ raise RuntimeError(f"Erro {resp.status_code} na página {page}: {resp.text[:500]}")
621
+
622
+ # RESET 429 quando OK
623
+ consecutive_429 = 0
624
+
625
+ # Parse JSON
626
+ try:
627
+ data = resp.json()
628
+ except Exception:
629
+ raise RuntimeError(f"JSON inválido na página {page}: {resp.text[:300]}")
630
+
631
+ # Normaliza linhas
632
+ if isinstance(data, list):
633
+ rows = data
634
+ elif isinstance(data, dict):
635
+ rows = data.get("data") or data.get("items") or []
636
+ if not isinstance(rows, list):
637
+ rows = []
638
+ else:
639
+ rows = []
640
+
641
+ # Fim se vazio
642
+ if not rows:
643
+ break
644
+
645
+ # Soma acumulado
646
+ all_rows.extend(rows)
647
+ st.session_state["op_pages_processed"] = page
648
+
649
+ # ----- Atualiza UI de progresso -----
650
+ status_text.info(f"🔄 Carregando página {page}…")
651
+ if denom:
652
+ frac = min(page / float(denom), 1.0)
653
+ progress_bar.progress(frac)
654
+ else:
655
+ progress_bar.progress((page % 10) / 10.0)
656
+
657
+ # Próxima página
658
+ page += 1
659
+
660
+ # Atraso entre páginas (rate limit)
661
+ if OP_RATE_DELAY_SEC > 0:
662
+ time.sleep(OP_RATE_DELAY_SEC)
663
+
664
+ # Concluído: barra 100%
665
+ progress_bar.progress(1.0)
666
+ time.sleep(0.1)
667
+
668
+ finally:
669
+ # Limpa componentes de UI sempre
670
+ try:
671
+ status_text.empty()
672
+ except Exception:
673
+ pass
674
+ try:
675
+ progress_bar.empty()
676
+ except Exception:
677
+ pass
678
+ st.session_state.pop("__op_progress_bar__", None)
679
+ st.session_state.pop("__op_status_text__", None)
680
+
681
+ return all_rows
682
+
683
+ # =====================================================
684
+ # EXPORTS
685
+ # =====================================================
686
+ def _export_excel(df: pd.DataFrame, report_key: str, filtros_aplicados: dict) -> bytes:
687
+ buffer = BytesIO()
688
+ with pd.ExcelWriter(buffer, engine="openpyxl") as writer:
689
+ df.to_excel(writer, index=False, sheet_name=report_key[:30] or "Relatorio")
690
+ meta_df = pd.DataFrame([filtros_aplicados])
691
+ meta_df.to_excel(writer, index=False, sheet_name="Filtros_Aplicados")
692
+ return buffer.getvalue()
693
+
694
+ def _export_csv(df: pd.DataFrame) -> bytes:
695
+ return df.to_csv(index=False, encoding="utf-8-sig").encode("utf-8-sig")
696
+
697
+ # =====================================================
698
+ # Helpers — JWT iat/exp
699
+ # =====================================================
700
+ def _jwt_payload(token: str):
701
+ try:
702
+ parts = token.split(".")
703
+ if len(parts) != 3:
704
+ return None
705
+ def b64url_to_b64(s): return s + "=" * (-len(s) % 4)
706
+ payload_b64 = b64url_to_b64(parts[1])
707
+ payload_json = base64.urlsafe_b64decode(payload_b64.encode("utf-8")).decode("utf-8")
708
+ return json.loads(payload_json)
709
+ except Exception:
710
+ return None
711
+
712
+ def _fmt_ts(ts):
713
+ try:
714
+ dt = datetime.utcfromtimestamp(int(ts))
715
+ return dt.strftime("%d/%m/%Y %H:%M:%S") + " UTC"
716
+ except Exception:
717
+ return "—"
718
+
719
+ # =====================================================
720
+ # Sugestões — cache (TTL configurável)
721
+ # =====================================================
722
+ @st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False)
723
+ def _load_suggestions_recipients(base_guid: str, token: str):
724
+ """Sugestões de Destinatários (desc+cnpj) a partir de /stock/list (1 página)."""
725
+ session = _get_session()
726
+ url = f"{OP_API_BASE_URL}/stock/list"
727
+ headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True)
728
+ try:
729
+ resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False),
730
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES)
731
+ if resp.status_code >= 400: return []
732
+ data = resp.json()
733
+ except Exception:
734
+ return []
735
+ rows = data if isinstance(data, list) else data.get("data") or data.get("items") or []
736
+ opts, seen = [], set()
737
+ for r in rows:
738
+ desc = str(r.get("recipient_description") or "").strip()
739
+ doc = str(r.get("recipient_document") or "").strip()
740
+ if not desc and not doc: continue
741
+ label = f"{desc} ({doc})" if doc else desc
742
+ key = (label, doc)
743
+ if key not in seen:
744
+ seen.add(key); opts.append((label, doc))
745
+ return opts
746
+
747
+ @st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False)
748
+ def _load_suggestions_notas(base_guid: str, token: str, path: str):
749
+ """Sugestões de Nota Fiscal a partir do endpoint corrente (1 página)."""
750
+ session = _get_session()
751
+ url = f"{OP_API_BASE_URL}{path}"
752
+ headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True)
753
+ try:
754
+ resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False),
755
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES)
756
+ if resp.status_code >= 400: return []
757
+ data = resp.json()
758
+ except Exception:
759
+ return []
760
+ rows = data if isinstance(data, list) else data.get("data") or data.get("items") or []
761
+ field = "wsreceipt_code" if path in ("/wsreceipt/list", "/stock/list", "/operation/list") else \
762
+ "wsdispatch_code" if path in ("/wsdispatch/list", "/financial/invoice/list") else None
763
+ opts, seen = [], set()
764
+ for r in rows:
765
+ nota = str(r.get(field) or "").strip() if field else ""
766
+ if nota and nota not in seen:
767
+ seen.add(nota); opts.append(nota)
768
+ return opts
769
+
770
+ @st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False)
771
+ def _load_suggestions_categories(base_guid: str, token: str, path: str):
772
+ """Sugestões de Categoria (1 página)."""
773
+ session = _get_session()
774
+ url = f"{OP_API_BASE_URL}{path}"
775
+ headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True)
776
+ try:
777
+ resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False),
778
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES)
779
+ if resp.status_code >= 400: return []
780
+ data = resp.json()
781
+ except Exception:
782
+ return []
783
+ rows = data if isinstance(data, list) else data.get("data") or data.get("items") or []
784
+ seen, opts = set(), []
785
+ for r in rows:
786
+ cat = str(r.get("category_description") or r.get("Categoria") or "").strip()
787
+ if cat and cat not in seen:
788
+ seen.add(cat); opts.append(cat)
789
+ return opts
790
+
791
+ @st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False)
792
+ def _load_suggestions_addresses(base_guid: str, token: str):
793
+ """Sugestões de Endereço (1 página)."""
794
+ session = _get_session()
795
+ url = f"{OP_API_BASE_URL}/address/list"
796
+ headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True)
797
+ try:
798
+ resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False),
799
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES)
800
+ if resp.status_code >= 400: return []
801
+ data = resp.json()
802
+ except Exception:
803
+ return []
804
+ rows = data if isinstance(data, list) else data.get("data") or data.get("items") or []
805
+ seen, opts = set(), []
806
+ for r in rows:
807
+ code = str(r.get("location_code") or r.get("location_id_code") or "").strip()
808
+ desc = str(r.get("description") or "").strip()
809
+ if not code: continue
810
+ label = f"{code} - {desc}" if desc else code
811
+ if code not in seen:
812
+ seen.add(code); opts.append((label, code))
813
+ return opts
814
+
815
+ @st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=False)
816
+ def _load_suggestions_depositantes(base_guid: str, token: str):
817
+ """Sugestões de Depositantes (nome+cnpj) a partir de /stock/list (1 página)."""
818
+ session = _get_session()
819
+ url = f"{OP_API_BASE_URL}/stock/list"
820
+ headers = _auth_headers(token, base_guid, page=1, limit=1000, is_post=True)
821
+ try:
822
+ resp = session.post(url, headers=headers, data=json.dumps({}, ensure_ascii=False),
823
+ timeout=(OP_CONNECT_TIMEOUT, OP_READ_TIMEOUT), proxies=PROXIES)
824
+ if resp.status_code >= 400: return []
825
+ data = resp.json()
826
+ except Exception:
827
+ return []
828
+ rows = data if isinstance(data, list) else data.get("data") or data.get("items") or []
829
+ opts, seen = [], set()
830
+ for r in rows:
831
+ name = str(r.get("customer_name") or r.get("customer_description") or r.get("Depositante") or "").strip()
832
+ doc = str(r.get("customer_document") or r.get("CNPJ_Depositante") or "").strip()
833
+ if not name and not doc: continue
834
+ label = f"{name} ({doc})" if doc else name
835
+ key = (label, doc)
836
+ if key not in seen:
837
+ seen.add(key); opts.append((label, doc))
838
+ return opts
839
+
840
+ # =====================================================
841
+ # Body mapping (filtros comuns) — server-side quando suportado
842
+ # =====================================================
843
+ def _map_common_filters_to_body(path: str, ui: dict) -> dict:
844
+ """
845
+ Para múltiplas seleções, a maioria dos endpoints não aceita array.
846
+ Estratégia: enviar 1 valor (o primeiro) no body e aplicar os demais client-side.
847
+ """
848
+ enderecos_sel = ui.get("enderecos_sel", [])
849
+ endereco_text = ui.get("endereco_text", "").strip()
850
+ nf_sel = ui.get("nota_fiscal_sel", "").strip()
851
+ sku_item = ui.get("sku_item", "").strip()
852
+ part_number = ui.get("part_number", "").strip()
853
+ destinatarios_docs = ui.get("destinatarios_docs", [])
854
+ cnpjs_livres = ui.get("cnpjs_livres", [])
855
+ data_op = ui.get("data_op") # date único
856
+ categorias_sel = ui.get("categorias_sel", [])
857
+ types_sel = ui.get("types_sel", [])
858
+ depositantes_docs = ui.get("depositantes_docs", []) # múltiplos CNPJs (sugestões)
859
+
860
+ mapped = {}
861
+
862
+ # Endereço
863
+ addr = (enderecos_sel[0] if enderecos_sel else "") or endereco_text
864
+ if addr:
865
+ mapped["location_code"] = addr
866
+
867
+ # Nota fiscal (apenas sugestões)
868
+ nf_val = nf_sel
869
+ if nf_val:
870
+ if path in ("/wsreceipt/list", "/stock/list", "/operation/list"):
871
+ mapped["wsreceipt_code"] = nf_val
872
+ if path in ("/wsdispatch/list", "/financial/invoice/list"):
873
+ mapped["wsdispatch_code"] = nf_val
874
+
875
+ # SKU / Part Number
876
+ if sku_item:
877
+ mapped["product_code"] = sku_item
878
+ if part_number:
879
+ mapped["part_number"] = part_number
880
+
881
+ # Categoria (1 server-side; demais client-side)
882
+ if len(categorias_sel) == 1:
883
+ mapped["category_description"] = categorias_sel[0]
884
+
885
+ # Destinatário CNPJ (1 server-side)
886
+ all_cnpjs_dest = [c for c in destinatarios_docs + cnpjs_livres if c]
887
+ if all_cnpjs_dest:
888
+ mapped["recipient_document"] = all_cnpjs_dest[0]
889
+
890
+ # Depositante CNPJ (1 server-side)
891
+ if depositantes_docs:
892
+ mapped["customer_document"] = depositantes_docs[0]
893
+
894
+ # Data operação — em /operation/list
895
+ if path == "/operation/list" and data_op:
896
+ mapped["date_ini"] = data_op.strftime("%Y-%m-%d")
897
+ mapped["date_fim"] = data_op.strftime("%Y-%m-%d")
898
+
899
+ # Type(API) — 1 server-side
900
+ if path == "/operation/list" and len(types_sel) == 1:
901
+ mapped["type"] = types_sel[0]
902
+
903
+ return mapped
904
+
905
+ # =====================================================
906
+ # Client-side filters (Data operação única, Categoria, múltiplos seleção)
907
+ # =====================================================
908
+ def _contains_ci(series: pd.Series, needle: str) -> pd.Series:
909
+ if series.dtype != "O":
910
+ series = series.astype("string")
911
+ return series.fillna("").str.contains(needle, case=False, na=False)
912
+
913
+ def _coalesce_datetime_ptbr(df: pd.DataFrame, candidates: list) -> pd.Series:
914
+ result = pd.Series([pd.NaT] * len(df), index=df.index)
915
+ for c in candidates:
916
+ if c in df.columns:
917
+ s = df[c]
918
+ s1 = pd.to_datetime(s, errors="coerce", dayfirst=True)
919
+ result = result.fillna(s1)
920
+ mask_nat = result.isna()
921
+ if mask_nat.any():
922
+ s2 = pd.to_datetime(s[mask_nat], errors="coerce", dayfirst=False)
923
+ result.loc[mask_nat] = s2
924
+ return result
925
+
926
+ def _classify_sbm(row: pd.Series) -> str:
927
+ """
928
+ Classificação SBM baseada em DEPOSITANTE (não em Destinatário):
929
+ - Se Depositante contém 'SBM' e Tipo == RECEIVING -> SBM - LOAD
930
+ - Se Depositante contém 'SBM' e Tipo == DISPATCH -> SBM - BACKLOAD
931
+ - Caso contrário -> OUTROS
932
+ """
933
+ depos = str(row.get("Depositante") or row.get("customer_name") or "").upper()
934
+ typ = str(row.get("Tipo") or row.get("type") or "").upper()
935
+ if "SBM" in depos:
936
+ if typ == "RECEIVING": return "SBM - LOAD"
937
+ if typ == "DISPATCH": return "SBM - BACKLOAD"
938
+ return "OUTROS"
939
+
940
+ def _apply_client_side_filters(df: pd.DataFrame, ui: dict, sbm_types_sel: list, types_sel: list) -> pd.DataFrame:
941
+ enderecos_sel = ui.get("enderecos_sel", [])
942
+ endereco_text = ui.get("endereco_text", "").strip()
943
+ nf_sel = ui.get("nota_fiscal_sel", "").strip()
944
+ sku_item = ui.get("sku_item", "").strip()
945
+ part_number = ui.get("part_number", "").strip()
946
+ destinatarios_docs = [c.strip() for c in ui.get("destinatarios_docs", []) if c.strip()]
947
+ cnpjs_livres = [c.strip() for c in ui.get("cnpjs_livres", []) if c.strip()]
948
+ data_op = ui.get("data_op") # único
949
+ categorias_sel = ui.get("categorias_sel", [])
950
+ depositantes_docs = [c.strip() for c in ui.get("depositantes_docs", []) if c.strip()]
951
+ depositantes_nomes = [s.strip() for s in ui.get("depositantes_nomes", []) if str(s).strip()]
952
+
953
+ # Endereços
954
+ addr_cols = [c for c in ["Endereço", "location_code", "location_id_code"] if c in df.columns]
955
+ if addr_cols:
956
+ if enderecos_sel:
957
+ mask = False
958
+ for c in addr_cols:
959
+ mask = df[c].isin(enderecos_sel) | mask
960
+ df = df[mask].copy()
961
+ if endereco_text:
962
+ mask = False
963
+ for c in addr_cols:
964
+ mask = _contains_ci(df[c], endereco_text) | mask
965
+ df = df[mask].copy()
966
+
967
+ # Nota Fiscal (apenas seleção)
968
+ if nf_sel:
969
+ cols = [c for c in ["Nota Fiscal", "NF Saída", "wsreceipt_code", "wsdispatch_code"] if c in df.columns]
970
+ mask = False
971
+ for c in cols:
972
+ mask = _contains_ci(df[c], nf_sel) | mask
973
+ df = df[mask].copy()
974
+
975
+ # SKU / item
976
+ if sku_item:
977
+ cols = [c for c in ["SKU", "product_code"] if c in df.columns]
978
+ mask = False
979
+ for c in cols:
980
+ mask = _contains_ci(df[c], sku_item) | mask
981
+ df = df[mask].copy()
982
+
983
+ # Part Number
984
+ if part_number and "part_number" in df.columns:
985
+ df = df[_contains_ci(df["part_number"], part_number)].copy()
986
+
987
+ # CNPJ Destinatários
988
+ all_cnpjs_dest = destinatarios_docs + cnpjs_livres
989
+ if all_cnpjs_dest:
990
+ cols = [c for c in ["CNPJ_Destinatário", "recipient_document"] if c in df.columns]
991
+ mask = False
992
+ for c in cols:
993
+ mask = df[c].isin(all_cnpjs_dest) | mask
994
+ df = df[mask].copy()
995
+
996
+ # Depositantes (por CNPJ e/ou por nome) — multisseleção
997
+ dep_doc_cols = [c for c in ["CNPJ_Depositante", "customer_document"] if c in df.columns]
998
+ dep_name_cols = [c for c in ["Depositante", "customer_name", "customer_description"] if c in df.columns]
999
+ if depositantes_docs and dep_doc_cols:
1000
+ mask = False
1001
+ for c in dep_doc_cols:
1002
+ mask = df[c].isin(depositantes_docs) | mask
1003
+ df = df[mask].copy()
1004
+ if depositantes_nomes and dep_name_cols:
1005
+ mask = False
1006
+ for c in dep_name_cols:
1007
+ mask_local = False
1008
+ for name in depositantes_nomes:
1009
+ mask_local = _contains_ci(df[c], name) | mask_local
1010
+ mask = mask | mask_local
1011
+ df = df[mask].copy()
1012
+
1013
+ # Data da operação (única)
1014
+ if data_op:
1015
+ op_date = _coalesce_datetime_ptbr(df, [
1016
+ "Data Operação","Data","Data Emissão","Data Agendada",
1017
+ "Validade","Fabricação",
1018
+ "date","create_date","issue_date","scheduled_date","expiration_date","manufacturing_date","last_update_date"
1019
+ ])
1020
+ data_ini = pd.to_datetime(data_op)
1021
+ data_fim = data_ini + pd.Timedelta(days=1) - pd.Timedelta(milliseconds=1)
1022
+ df = df[(op_date >= data_ini) & (op_date <= data_fim)].copy()
1023
+
1024
+ # Categoria
1025
+ if categorias_sel:
1026
+ cols = [c for c in ["Categoria", "category_description"] if c in df.columns]
1027
+ mask = False
1028
+ for c in cols:
1029
+ mask = df[c].isin(categorias_sel) | mask
1030
+ df = df[mask].copy()
1031
+
1032
+ # Tipo (API) multisseleção ('Tipo' ou 'type')
1033
+ if types_sel and ("Tipo" in df.columns or "type" in df.columns):
1034
+ col_t = "Tipo" if "Tipo" in df.columns else "type"
1035
+ df = df[df[col_t].isin(types_sel)].copy()
1036
+
1037
+ # Tipo de operação (SBM) — baseado em DEPOSITANTE
1038
+ if sbm_types_sel:
1039
+ sbm_series = df.apply(_classify_sbm, axis=1)
1040
+ df = df[sbm_series.isin(sbm_types_sel)].copy()
1041
+
1042
+ return df
1043
+
1044
+ # =====================================================
1045
+ # Formatação (R$, SKU, datas PT-BR, remove colunas vazias)
1046
+ # =====================================================
1047
+ def _format_currency_brl(val):
1048
+ try:
1049
+ x = float(val)
1050
+ except Exception:
1051
+ return val
1052
+ s = f"{x:,.2f}"
1053
+ s = s.replace(",", "X").replace(".", ",").replace("X", ".")
1054
+ return f"R$ {s}"
1055
+
1056
+ def _format_dates_ptbr(df: pd.DataFrame) -> pd.DataFrame:
1057
+ candidate_cols = [
1058
+ "Data Operação","Data","Data Emissão","Validade","Fabricação",
1059
+ "Data Agendada","date","create_date","issue_date","expiration_date",
1060
+ "manufacturing_date","scheduled_date","last_update_date"
1061
+ ]
1062
+ for col in df.columns:
1063
+ if col in candidate_cols:
1064
+ try:
1065
+ s = pd.to_datetime(df[col], errors="coerce", dayfirst=True)
1066
+ nat_mask = s.isna()
1067
+ if nat_mask.any():
1068
+ s.loc[nat_mask] = pd.to_datetime(df[col][nat_mask], errors="coerce", dayfirst=False)
1069
+ df[col] = s.dt.strftime("%d/%m/%Y").fillna(df[col])
1070
+ except Exception:
1071
+ pass
1072
+ return df
1073
+
1074
+ def _format_dataframe(df: pd.DataFrame) -> pd.DataFrame:
1075
+ if df.empty:
1076
+ return df
1077
+
1078
+ # R$
1079
+ money_candidates = ["Vr. Total", "Vr. Unitário", "Item_Value",
1080
+ "item_total_value", "item_unit_value", "receipt_value", "total_value"]
1081
+ for col in money_candidates:
1082
+ if col in df.columns:
1083
+ df[col] = df[col].apply(_format_currency_brl)
1084
+
1085
+ # SKU sem zeros à esquerda
1086
+ for col in ["SKU", "product_code"]:
1087
+ if col in df.columns:
1088
+ def strip_zeros(v):
1089
+ s = str(v or "").strip()
1090
+ if not s:
1091
+ return s
1092
+ s2 = s.lstrip("0")
1093
+ return s2 if s2 else "0"
1094
+ df[col] = df[col].apply(strip_zeros)
1095
+
1096
+ # Datas PT-BR
1097
+ df = _format_dates_ptbr(df)
1098
+
1099
+ # Remove colunas vazias
1100
+ to_drop = []
1101
+ for col in df.columns:
1102
+ serie = df[col]
1103
+ empties = serie.isna() | (serie.astype(str).str.strip() == "")
1104
+ if empties.all():
1105
+ to_drop.append(col)
1106
+ if to_drop:
1107
+ df = df.drop(columns=to_drop)
1108
+
1109
+ return df
1110
+
1111
+ # =====================================================
1112
+ # Helpers de KPI (somas seguras, moeda BRL, contagens)
1113
+ # =====================================================
1114
+ def _to_num_brl(val) -> float:
1115
+ """Converte 'R$ 1.234,56' ou num/str para float (1234.56)."""
1116
+ if val is None: return 0.0
1117
+ if isinstance(val, (int, float)): return float(val)
1118
+ s = str(val).strip()
1119
+ if not s: return 0.0
1120
+ s = s.replace("R$", "").replace(" ", "")
1121
+ s = s.replace(".", "").replace(",", ".")
1122
+ try:
1123
+ return float(s)
1124
+ except Exception:
1125
+ return 0.0
1126
+
1127
+ def _safe_sum(series: pd.Series) -> float:
1128
+ """Soma segura de séries possivelmente formatadas em BRL (strings)."""
1129
+ if series is None or series.empty: return 0.0
1130
+ return float(series.apply(_to_num_brl).sum())
1131
+
1132
+ def _nunique(df: pd.DataFrame, colnames: list) -> int:
1133
+ """Conta distintos na primeira coluna existente em 'colnames'."""
1134
+ for c in colnames:
1135
+ if c in df.columns:
1136
+ return int(df[c].nunique(dropna=True))
1137
+ return 0
1138
+
1139
+ def _count_status(df: pd.DataFrame, col: str, positivos=("ATIVO","ACTIVE","BLOQUEADO","BLOCKED")) -> int:
1140
+ """Conta registros com 'Status' em valores positivos (case-insensitive)."""
1141
+ if col not in df.columns: return 0
1142
+ s = df[col].astype(str).str.upper()
1143
+ return int(s.isin([p.upper() for p in positivos]).sum())
1144
+
1145
+ # =====================================================
1146
+ # KPIs dinâmicos por endpoint (usa colunas pós-RENAME_MAP)
1147
+ # =====================================================
1148
+ def _build_kpis(path: str, df: pd.DataFrame) -> dict:
1149
+ kpis = {}
1150
+
1151
+ if path == "/address/blocking/list":
1152
+ kpis["Endereços bloqueados (distintos)"] = _nunique(df, ["Endereço","location_code","location_id_code"])
1153
+ kpis["Bloqueios (linhas)"] = int(len(df))
1154
+ kpis["Motivos de bloqueio (distintos)"] = _nunique(df, ["Motivo Bloqueio","block_reason"])
1155
+ if "Status" in df.columns:
1156
+ kpis["Bloqueios ativos"] = _count_status(df, "Status", positivos=("BLOQUEADO","BLOCKED"))
1157
+
1158
+ elif path == "/address/list":
1159
+ kpis["Endereços (distintos)"] = _nunique(df, ["Endereço","location_code","location_id_code"])
1160
+ if "Status" in df.columns:
1161
+ kpis["Endereços ativos"] = _count_status(df, "Status", positivos=("ATIVO","ACTIVE"))
1162
+
1163
+ elif path == "/stock/list":
1164
+ # IMPORTANT: Valor total em estoque deve vir DA COLUNA 'Vr. Total'
1165
+ qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0
1166
+ qtde_reserva = _safe_sum(df["Qtde Reservada"]) if "Qtde Reservada" in df.columns else 0.0
1167
+ valor_total = _safe_sum(df["Vr. Total"]) if "Vr. Total" in df.columns else 0.0
1168
+
1169
+ kpis["SKUs (distintos)"] = _nunique(df, ["SKU","product_code"])
1170
+ kpis["Itens em estoque (Qtde)"] = qtde_total
1171
+ kpis["Itens reservados (Qtde)"] = qtde_reserva
1172
+ kpis["Valor total em estoque (R$)"] = valor_total
1173
+ kpis["Lotes (distintos)"] = _nunique(df, ["Lote","lot"])
1174
+ kpis["Endereços (distintos)"] = _nunique(df, ["Endereço","location_code"])
1175
+ kpis["Depositantes (distintos)"] = _nunique(df, ["Depositante","customer_description","customer_name"])
1176
+
1177
+ elif path == "/wsreceipt/list":
1178
+ qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0
1179
+ valor_total = _safe_sum(df["Vr. Total"]) if "Vr. Total" in df.columns else 0.0
1180
+ kpis["NF de entrada (distintas)"] = _nunique(df, ["NF Entrada","wsreceipt_code"])
1181
+ kpis["Linhas (itens)"] = int(len(df))
1182
+ kpis["Qtde total (Entrada)"] = qtde_total
1183
+ kpis["Valor total (Entrada) R$"] = valor_total
1184
+ kpis["Depositantes (distintos)"] = _nunique(df, ["Depositante","customer_name"])
1185
+
1186
+ elif path == "/wsdispatch/list":
1187
+ qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0
1188
+ valor_total = _safe_sum(df["Vr. Total"]) if "Vr. Total" in df.columns else 0.0
1189
+ kpis["NF de saída (distintas)"] = _nunique(df, ["NF Saída","wsdispatch_code"])
1190
+ kpis["Linhas (itens)"] = int(len(df))
1191
+ kpis["Qtde total (Saída)"] = qtde_total
1192
+ kpis["Valor total (Saída) R$"] = valor_total
1193
+ kpis["Destinatários (distintos)"] = _nunique(df, ["Destinatário","recipient_description"])
1194
+
1195
+ elif path == "/operation/list":
1196
+ qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0
1197
+ kpis["Operações (linhas)"] = int(len(df))
1198
+ kpis["Tipos (distintos)"] = _nunique(df, ["Tipo","type"])
1199
+ kpis["Qtde total (Operações)"] = qtde_total
1200
+
1201
+ elif path == "/product/list":
1202
+ kpis["Produtos (linhas)"] = int(len(df))
1203
+ kpis["Categorias (distintas)"] = _nunique(df, ["Categoria","category_description"])
1204
+ kpis["Grupos (distintos)"] = _nunique(df, ["Grupo","group_description"])
1205
+ kpis["Status (distintos)"] = _nunique(df, ["Status","status"])
1206
+
1207
+ elif path == "/financial/invoice/list":
1208
+ valor_total = _safe_sum(df["Vr. Total"]) if "Vr. Total" in df.columns else 0.0
1209
+ kpis["Faturas (distintas)"] = _nunique(df, ["Número Fatura","invoice_number"])
1210
+ kpis["Valor total (R$)"] = valor_total
1211
+ kpis["Status (distintos)"] = _nunique(df, ["Status","status"])
1212
+
1213
+ elif path == "/monitor/nfe/list":
1214
+ kpis["NFes (distintas)"] = _nunique(df, ["Chave NFe","nfe_key"])
1215
+ kpis["Status SEFAZ (distintos)"] = _nunique(df, ["Status SEFAZ","status"])
1216
+
1217
+ elif path == "/yms/scheduling/list":
1218
+ kpis["Agendamentos (distintos)"] = _nunique(df, ["ID Agendamento","scheduling_id"])
1219
+ kpis["Status (distintos)"] = _nunique(df, ["Status","status"])
1220
+ kpis["Pátios (distintos)"] = _nunique(df, ["Pátio","yard"])
1221
+ kpis["Docas (distintas)"] = _nunique(df, ["Doca","dock"])
1222
+
1223
+ elif path == "/cargorelease/list":
1224
+ qtde_total = _safe_sum(df["Qtde"]) if "Qtde" in df.columns else 0.0
1225
+ kpis["Pedidos (distintos)"] = _nunique(df, ["Pedido","cargorelease_code"])
1226
+ kpis["Status (distintos)"] = _nunique(df, ["Status","status"])
1227
+ kpis["Qtde total (Pedidos)"] = qtde_total
1228
+
1229
+ else:
1230
+ kpis["Linhas"] = int(len(df))
1231
+
1232
+ return kpis
1233
+
1234
+ # =====================================================
1235
+ # Cache de consulta (DF final) — TTL configurável
1236
+ # =====================================================
1237
+ @st.cache_data(ttl=CACHE_TTL_SEC, show_spinner=True)
1238
+ 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):
1239
+ rows = _call_list_paginated(
1240
+ path, base_guid, token, body_filter,
1241
+ limit=limit_per_page,
1242
+ max_pages_override=max_pages_override
1243
+ )
1244
+ df = pd.json_normalize(rows)
1245
+ rename = RENAME_MAP.get(path, {})
1246
+ if rename:
1247
+ df = df.rename(columns={k: v for k, v in rename.items() if k in df.columns})
1248
+ df = _apply_client_side_filters(df, ui_common, sbm_types_sel, ui_common.get("types_sel", []))
1249
+ df = _format_dataframe(df)
1250
+ return df
1251
+
1252
+ # =====================================================
1253
+ # UI — Type(API), Destinatários, Endereços; Data operação (única); Categoria; SBM; Depositantes
1254
+ # + Modo rápido (consulta leve) e limite por página
1255
+ # + Debounce e Cancelar
1256
+ # =====================================================
1257
+ def _render_form(report_label: str, token: str):
1258
+ with st.form(f"form_{report_label.replace(' ', '_')}"):
1259
+ base = st.selectbox("Base (x-user-enterprise-id):", list(BASES_MAP.keys()), index=0)
1260
+
1261
+ # Performance
1262
+ col_perf1, col_perf2 = st.columns(2)
1263
+ with col_perf1:
1264
+ quick_mode = st.checkbox("Consulta leve (rápida)", value=True, help="Coleta apenas 1 página e mostra colunas essenciais.")
1265
+ with col_perf2:
1266
+ 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.")
1267
+
1268
+ # Paginação manual (quando necessário)
1269
+ colp1, colp2 = st.columns(2)
1270
+ with colp1:
1271
+ only_first = st.checkbox("Coletar apenas a primeira página (evitar limite/timeouts)", value=False)
1272
+ with colp2:
1273
+ max_pages_ui = st.number_input("Máximo de páginas (0=∞)", min_value=0, max_value=100, value=0, step=1)
1274
+
1275
+ path = ENDPOINTS[report_label]
1276
+
1277
+ # Type (API) multisseleção
1278
+ types_sel = st.multiselect("Type (API)", OPERATION_TYPES, default=[])
1279
+
1280
+ # Data da operação (única)
1281
+ data_op = st.date_input("Data da operação (opcional)", value=None)
1282
+
1283
+ # Categoria (sugestões) multisseleção
1284
+ categorias_opts = _load_suggestions_categories(BASES_MAP[base], token, path)
1285
+ categorias_sel = st.multiselect("Categoria (sugestões)", categorias_opts, default=[])
1286
+
1287
+ # Endereços (sugestões) multisseleção + texto
1288
+ addr_opts = _load_suggestions_addresses(BASES_MAP[base], token) # [(label, code)]
1289
+ addr_labels = [lbl for (lbl, code) in addr_opts]
1290
+ addr_values = [code for (lbl, code) in addr_opts]
1291
+ end_sel_labels = st.multiselect("Endereços (sugestões)", addr_labels, default=[])
1292
+ enderecos_sel = []
1293
+ for sel in end_sel_labels:
1294
+ i = addr_labels.index(sel)
1295
+ enderecos_sel.append(addr_values[i])
1296
+ endereco_text = st.text_input("Endereço (texto)")
1297
+
1298
+ # Nota Fiscal (apenas sugestões)
1299
+ nf_opts = _load_suggestions_notas(BASES_MAP[base], token, path)
1300
+ nota_fiscal_sel = st.selectbox("Nota Fiscal (sugestões)", [""] + nf_opts, index=0)
1301
+
1302
+ # Item / SKU e Part Number
1303
+ colA, colB = st.columns(2)
1304
+ with colA:
1305
+ sku_item = st.text_input("Item / SKU")
1306
+ with colB:
1307
+ part_number = st.text_input("Part Number")
1308
+
1309
+ # Destinatários (sugestões multisseleção) + CNPJ múltiplos
1310
+ dest_opts = _load_suggestions_recipients(BASES_MAP[base], token) # [(label, cnpj)]
1311
+ dest_labels = [lbl for (lbl, cnpj) in dest_opts]
1312
+ dest_values = [cnpj for (lbl, cnpj) in dest_opts]
1313
+ dest_sel_labels = st.multiselect("Destinatários (sugestões)", dest_labels, default=[])
1314
+ destinatarios_docs = []
1315
+ for sel in dest_sel_labels:
1316
+ i = dest_labels.index(sel)
1317
+ destinatarios_docs.append(dest_values[i])
1318
+
1319
+ cnpj_livre_text = st.text_input("CNPJ(s) do destinatário (múltiplos, separados por vírgula)")
1320
+ cnpjs_livres = [c.strip() for c in cnpj_livre_text.split(",")] if cnpj_livre_text.strip() else []
1321
+
1322
+ # Depositantes (sugestões multisseleção)
1323
+ dep_opts = _load_suggestions_depositantes(BASES_MAP[base], token) # [(label, cnpj)]
1324
+ dep_labels = [lbl for (lbl, cnpj) in dep_opts]
1325
+ dep_values = [cnpj for (lbl, cnpj) in dep_opts]
1326
+ dep_sel_labels = st.multiselect("Depositantes (sugestões)", dep_labels, default=[])
1327
+ depositantes_docs, depositantes_nomes = [], []
1328
+ for sel in dep_sel_labels:
1329
+ i = dep_labels.index(sel)
1330
+ depositantes_docs.append(dep_values[i])
1331
+ # Também guardamos o nome (parte antes do " (CNPJ)") para filtro por nome, se desejar
1332
+ name = sel.split(" (")[0].strip() if " (" in sel else sel
1333
+ depositantes_nomes.append(name)
1334
+
1335
+ # Tipo de operação (SBM) multisseleção — baseado em Depositante
1336
+ sbm_types_sel = st.multiselect("Tipo de operação (SBM)", SBM_TYPES, default=[]) if report_label == "Estoque" else []
1337
+
1338
+ # Server-side (quando suportado)
1339
+ body = {}
1340
+ if report_label == "Operações":
1341
+ st.markdown("**Filtros de Operações (server-side)**")
1342
+ if len(types_sel) == 1:
1343
+ body["type"] = types_sel[0]
1344
+ if data_op:
1345
+ body["date_ini"] = data_op.strftime("%Y-%m-%d")
1346
+ body["date_fim"] = data_op.strftime("%Y-%m-%d")
1347
+
1348
+ elif report_label == "Agendamento":
1349
+ st.markdown("**Filtros de Agendamento (YMS)**")
1350
+ if data_op:
1351
+ body["date_ini"] = data_op.strftime("%Y-%m-%d")
1352
+ body["date_fim"] = data_op.strftime("%Y-%m-%d")
1353
+
1354
+ ui_common = {
1355
+ "enderecos_sel": enderecos_sel,
1356
+ "endereco_text": endereco_text,
1357
+ "nota_fiscal_sel": nota_fiscal_sel, # sem campo texto
1358
+ "sku_item": sku_item,
1359
+ "part_number": part_number,
1360
+ "destinatarios_docs": destinatarios_docs,
1361
+ "cnpjs_livres": cnpjs_livres,
1362
+ "data_op": data_op, # único
1363
+ "categorias_sel": categorias_sel,
1364
+ "types_sel": types_sel,
1365
+ "depositantes_docs": depositantes_docs, # multisseleção
1366
+ "depositantes_nomes": depositantes_nomes, # para match por nome, se desejar
1367
+ }
1368
+
1369
+ mapped = _map_common_filters_to_body(path, ui_common)
1370
+ body.update({k: v for k, v in mapped.items() if v})
1371
+
1372
+ submitted = st.form_submit_button("Consultar", type="primary", use_container_width=True)
1373
+
1374
+ # Debounce leve
1375
+ if submitted:
1376
+ last_submit = st.session_state.get("__last_submit_ts__", 0)
1377
+ now = time.time()
1378
+ if now - last_submit < 2.0:
1379
+ st.info("Aguarde um instante e evite clicar repetidamente em 'Consultar'.")
1380
+ submitted = False
1381
+ st.session_state["__last_submit_ts__"] = now
1382
+
1383
+ max_pages_effective = 1 if only_first else int(max_pages_ui or 0)
1384
+ return (
1385
+ submitted,
1386
+ BASES_MAP[base],
1387
+ max_pages_effective,
1388
+ body,
1389
+ ui_common,
1390
+ sbm_types_sel,
1391
+ quick_mode,
1392
+ limit_per_page,
1393
+ )
1394
+
1395
+ # =====================================================
1396
+ # TELA PRINCIPAL
1397
+ # =====================================================
1398
+ def main():
1399
+ # Oculta nav lateral de outros módulos
1400
+ st.markdown("""
1401
+ <style>
1402
+ [data-testid="stSidebarNav"] { display: none !important; }
1403
+ </style>
1404
+ """, unsafe_allow_html=True)
1405
+
1406
+ st.title("⚙️ Operação | Relatórios via API (Mayasuite)")
1407
+
1408
+ # Permissão
1409
+ if not verificar_permissao("operacao") and st.session_state.get("perfil") != "admin":
1410
+ st.error("⛔ Acesso não autorizado.")
1411
+ return
1412
+
1413
+ 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):
1414
+ 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).")
1415
+ st.info("Ex.: OP_LOGIN_EMAIL=api@armmatriz.com.br / OP_LOGIN_PASSWORD=*** OU OP_ACCESS_TOKEN=jwt... OU OAuth2 client_credentials.")
1416
+ return
1417
+
1418
+ # Token antes do form (sugestões)
1419
+ try:
1420
+ token = _get_token(OP_LOGIN_EMAIL, OP_LOGIN_PASSWORD)
1421
+ except Exception as e:
1422
+ st.error(f"Login/API falhou: {e}")
1423
+ return
1424
+
1425
+ # Sidebar: token info
1426
+ if OP_ACCESS_TOKEN:
1427
+ payload = _jwt_payload(OP_ACCESS_TOKEN)
1428
+ if payload:
1429
+ st.sidebar.caption(f"🔑 Token carregado\n• iat: {_fmt_ts(payload.get('iat'))}\n• exp: {_fmt_ts(payload.get('exp'))}")
1430
+ st.sidebar.caption(f"🌐 Base API: {OP_API_BASE_URL}")
1431
+
1432
+ st.session_state["__active_module__"] = "operacao"
1433
+
1434
+ st.sidebar.markdown("### Relatórios")
1435
+ report_label = st.sidebar.selectbox("Selecione:", list(ENDPOINTS.keys()), index=0)
1436
+ path = ENDPOINTS[report_label]
1437
+
1438
+ # ⚡ Cache controls (TTL, limpar caches)
1439
+ with st.sidebar.expander("⚡ Cache", expanded=False):
1440
+ ttl_ui = st.slider("TTL do cache (minutos)", min_value=1, max_value=60, value=CACHE_TTL_MIN, step=1)
1441
+ st.session_state["__cache_ttl_sec__"] = ttl_ui * 60
1442
+ colc1, colc2 = st.columns(2)
1443
+ with colc1:
1444
+ if st.button("🧹 Limpar cache de dados"):
1445
+ try:
1446
+ _run_query_cached.clear()
1447
+ except Exception:
1448
+ pass
1449
+ st.success("Cache de dados limpo.")
1450
+ st.rerun()
1451
+ with colc2:
1452
+ if st.button("🧹 Limpar cache de sugestões"):
1453
+ try:
1454
+ _load_suggestions_recipients.clear()
1455
+ _load_suggestions_notas.clear()
1456
+ _load_suggestions_categories.clear()
1457
+ _load_suggestions_addresses.clear()
1458
+ _load_suggestions_depositantes.clear()
1459
+ except Exception:
1460
+ pass
1461
+ st.success("Cache de sugestões limpo.")
1462
+ st.rerun()
1463
+
1464
+ submitted, base_guid, max_pages_effective, body_filter, ui_common, sbm_types_sel, quick_mode, limit_per_page = _render_form(report_label, token)
1465
+ if not submitted:
1466
+ return
1467
+
1468
+ # Botão cancelar
1469
+ cancel_col, _ = st.columns([1,3])
1470
+ with cancel_col:
1471
+ if st.button("⛔ Cancelar consulta"):
1472
+ st.session_state[CANCEL_TOKEN_KEY] = True
1473
+ st.warning("Consulta cancelada pelo usuário.")
1474
+ return
1475
+ st.session_state[CANCEL_TOKEN_KEY] = False
1476
+
1477
+ # Modo rápido → força apenas 1 página
1478
+ effective_max_pages = 1 if quick_mode else (max_pages_effective if max_pages_effective > 0 else None)
1479
+
1480
+ # Executa consulta com cache
1481
+ t0 = time.time()
1482
+ try:
1483
+ df = _run_query_cached(path, base_guid, token, body_filter, limit_per_page, effective_max_pages, ui_common, sbm_types_sel)
1484
+ except Exception as e:
1485
+ st.error(f"Falha na consulta: {e}")
1486
+ # Botão para tentar novamente
1487
+ if st.button("🔁 Tentar novamente agora"):
1488
+ try:
1489
+ _run_query_cached.clear()
1490
+ df = _run_query_cached(path, base_guid, token, body_filter, limit_per_page, effective_max_pages, ui_common, sbm_types_sel)
1491
+ except Exception as e2:
1492
+ st.error(f"Falha na nova tentativa: {e2}")
1493
+ return
1494
+ else:
1495
+ return
1496
+ latency_ms = int((time.time() - t0) * 1000)
1497
+
1498
+ if df.empty:
1499
+ st.info("Nenhum registro retornado.")
1500
+ return
1501
+
1502
+ st.success(f"✅ Consulta concluída ({latency_ms} ms). Registros: {len(df)}")
1503
+ pages_done = int(st.session_state.get("op_pages_processed", 0))
1504
+ st.caption(f"📄 Páginas coletadas: {pages_done}")
1505
+
1506
+ # Visualização essencial em modo rápido
1507
+ if quick_mode:
1508
+ essential_cols = [c for c in [
1509
+ "Data Operação","Data","Nota Fiscal","NF Saída","SKU","Descrição","Qtde",
1510
+ "Unidade","Endereço","Categoria","Destinatário","CNPJ_Destinatário","Tipo","Depositante","CNPJ_Depositante"
1511
+ ] if c in df.columns]
1512
+ df_view = df[essential_cols].copy() if essential_cols else df
1513
+ else:
1514
+ df_view = df
1515
+
1516
+ # KPIs básicos
1517
+ k1, k2, k3 = st.columns(3)
1518
+ with k1: st.metric("Registros", len(df_view))
1519
+ with k2: st.metric("Latência (ms)", latency_ms)
1520
+ with k3: st.metric("Colunas", df_view.shape[1])
1521
+
1522
+ # ======== NOVOS CARDS DINÂMICOS POR CONSULTA ========
1523
+ kpis = _build_kpis(path, df) # usa o df já renomeado/filtrado
1524
+ if kpis:
1525
+ st.markdown("#### 📈 Indicadores da consulta")
1526
+ cols = st.columns(min(4, max(1, len(kpis))))
1527
+ for i, (label, value) in enumerate(kpis.items()):
1528
+ # Formata moeda R$ quando aplicável
1529
+ if isinstance(value, (int, float)) and ("R$" in label or "Valor" in label):
1530
+ display_val = _format_currency_brl(value)
1531
+ else:
1532
+ display_val = value
1533
+ cols[i % len(cols)].metric(label, display_val)
1534
+
1535
+ # Tabela
1536
+ st.dataframe(df_view, use_container_width=True)
1537
+
1538
+ # Export
1539
+ col_a, col_b = st.columns(2)
1540
+ with col_a:
1541
+ 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}
1542
+ excel_bytes = _export_excel(df, report_label, filtros_export)
1543
+ st.download_button("📥 Exportar Excel", excel_bytes, file_name=f"operacao_{report_label.replace(' ','_')}.xlsx",
1544
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
1545
+ with col_b:
1546
+ csv_bytes = _export_csv(df)
1547
+ st.download_button("📥 Exportar CSV", csv_bytes, file_name=f"operacao_{report_label.replace(' ','_')}.csv", mime="text/csv")
1548
+
1549
+ # Auditoria
1550
+ try:
1551
+ registrar_log(usuario=st.session_state.get("usuario"),
1552
+ acao=f"Operação/API: {report_label} (base={base_guid}, {latency_ms}ms, reg={len(df)})",
1553
+ tabela="operacao_api", registro_id=None)
1554
+ except Exception:
1555
+ pass
1556
+
1557
+ # Se for executado diretamente (opcional para debug local)
1558
+ if __name__ == "__main__":
1559
+ st.set_page_config(page_title="Operação | ARM", layout="wide")
1560
+ main()
1561
+
1562
+
1563
+
1564
+
outlook_relatorio.py ADDED
@@ -0,0 +1,1624 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ # -*- coding: utf-8 -*-
3
+ import streamlit as st
4
+ import pandas as pd
5
+ from datetime import datetime, timedelta, date
6
+ import io
7
+ import re
8
+ import unicodedata
9
+ import pythoncom # ✅ COM init/finalize para evitar 'CoInitialize não foi chamado'
10
+
11
+ # (opcional) auditoria, se existir no seu projeto
12
+ try:
13
+ from utils_auditoria import registrar_log
14
+ _HAS_AUDIT = True
15
+ except Exception:
16
+ _HAS_AUDIT = False
17
+
18
+ # ==============================
19
+ # 🎨 Estilos (UX)
20
+ # ==============================
21
+ _STYLES = """
22
+ <style>
23
+ /* --- Cards/KPIs --- */
24
+ .kpi-wrap {display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:6px 0 8px 0;}
25
+ .kpi-card {border:1px solid rgba(0,0,0,.06);border-radius:12px;padding:14px;background:#ffffff;
26
+ box-shadow: 0 1px 3px rgba(0,0,0,.04);}
27
+ .kpi-top {display:flex;align-items:center;gap:10px;margin-bottom:6px;}
28
+ .kpi-ico {font-size:22px;line-height:1;}
29
+ .kpi-title {font-size:12px;font-weight:700;color:#6c757d;letter-spacing:.2px;text-transform:uppercase;}
30
+ .kpi-val {font-size:26px;font-weight:800;color:#0b1320;margin-top:2px;}
31
+ .kpi-note {font-size:12px;color:#6c757d;margin-top:4px;}
32
+
33
+ /* --- Barra de status --- */
34
+ .status-bar {border:1px solid rgba(0,0,0,.06);border-radius:12px;padding:10px 12px;margin:4px 0 10px 0;
35
+ background:linear-gradient(180deg, rgba(248,249,250,.9), rgba(255,255,255,.9));}
36
+ .status-line {display:flex;flex-wrap:wrap;gap:10px;font-size:13px;color:#495057;}
37
+ .badge {display:inline-flex;align-items:center;gap:6px;padding:4px 8px;border-radius:999px;background:#eef2ff;color:#1d4ed8;
38
+ border:1px solid #e0e7ff;font-weight:600;}
39
+ .badge.gray {background:#f8f9fa;color:#495057;border:1px solid #e9ecef;}
40
+ .badge.green {background:#e7f6ec;color:#1b5e20;border:1px solid #cdebd7;}
41
+ .badge.amber {background:#fff4e5;color:#8a4b08;border:1px solid #ffe2bf;}
42
+
43
+ /* --- Toolbar Downloads (fixa no rodapé) --- */
44
+ .dl-bar {position:sticky;bottom:8px;z-index:99;display:flex;gap:10px;padding:8px;background:rgba(255,255,255,.85);
45
+ border:1px solid rgba(0,0,0,.06);border-radius:12px;backdrop-filter:saturate(180%) blur(8px);}
46
+
47
+ @media (max-width: 900px){ .kpi-wrap{grid-template-columns:1fr 1fr;} }
48
+ @media (max-width: 600px){ .kpi-wrap{grid-template-columns:1fr;} .dl-bar{flex-wrap:wrap;} }
49
+ </style>
50
+ """
51
+
52
+ # ==============================
53
+ # Utils — exportação / indicadores
54
+ # ==============================
55
+ def _build_downloads(df: pd.DataFrame, base_name: str):
56
+ """Cria botões de download (CSV, Excel e PDF) para o DataFrame."""
57
+ if df.empty:
58
+ st.warning("Nenhum dado para exportar.")
59
+ return
60
+
61
+ st.markdown('<div class="dl-bar">', unsafe_allow_html=True)
62
+
63
+ # CSV
64
+ csv_buf = io.StringIO()
65
+ df.to_csv(csv_buf, index=False, encoding="utf-8-sig")
66
+ st.download_button(
67
+ "⬇️ Baixar CSV",
68
+ data=csv_buf.getvalue(),
69
+ file_name=f"{base_name}.csv",
70
+ mime="text/csv",
71
+ key=f"dl_csv_{base_name}"
72
+ )
73
+
74
+ # Excel (com autoajuste de larguras)
75
+ xlsx_buf = io.BytesIO()
76
+ with pd.ExcelWriter(xlsx_buf, engine="openpyxl") as writer:
77
+ df.to_excel(writer, index=False, sheet_name="Relatorio")
78
+ ws = writer.sheets["Relatorio"]
79
+
80
+ # 🔎 Ajuste automático de largura das colunas
81
+ from openpyxl.utils import get_column_letter
82
+ for col_idx, col_cells in enumerate(ws.columns, start=1):
83
+ max_len = 0
84
+ for cell in col_cells:
85
+ try:
86
+ v = "" if cell.value is None else str(cell.value)
87
+ max_len = max(max_len, len(v))
88
+ except Exception:
89
+ pass
90
+ ws.column_dimensions[get_column_letter(col_idx)].width = min(max_len + 2, 60)
91
+
92
+ xlsx_buf.seek(0)
93
+ st.download_button(
94
+ "⬇️ Baixar Excel",
95
+ data=xlsx_buf,
96
+ file_name=f"{base_name}.xlsx",
97
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
98
+ key=f"dl_xlsx_{base_name}"
99
+ )
100
+
101
+ # PDF (resumo até 100 linhas)
102
+ try:
103
+ from reportlab.lib.pagesizes import A4, landscape
104
+ from reportlab.platypus import SimpleDocTemplate, Table, TableStyle, Paragraph, Spacer
105
+ from reportlab.lib import colors
106
+ from reportlab.lib.styles import getSampleStyleSheet
107
+
108
+ pdf_buf = io.BytesIO()
109
+ doc = SimpleDocTemplate(
110
+ pdf_buf, pagesize=landscape(A4),
111
+ rightMargin=20, leftMargin=20, topMargin=20, bottomMargin=20
112
+ )
113
+ styles = getSampleStyleSheet()
114
+ story = [Paragraph(f"Relatório de E-mails — {base_name}", styles["Title"]), Spacer(1, 12)]
115
+ df_show = df.copy().head(100)
116
+ data_table = [list(df_show.columns)] + df_show.astype(str).values.tolist()
117
+ table = Table(data_table, repeatRows=1)
118
+ table.setStyle(TableStyle([
119
+ ("BACKGROUND", (0,0), (-1,0), colors.HexColor("#E9ECEF")),
120
+ ("TEXTCOLOR", (0,0), (-1,0), colors.HexColor("#212529")),
121
+ ("GRID", (0,0), (-1,-1), 0.25, colors.HexColor("#ADB5BD")),
122
+ ("FONTNAME", (0,0), (-1,0), "Helvetica-Bold"),
123
+ ("FONTNAME", (0,1), (-1,-1), "Helvetica"),
124
+ ("FONTSIZE", (0,0), (-1,-1), 9),
125
+ ("ALIGN", (0,0), (-1,-1), "LEFT"),
126
+ ("VALIGN", (0,0), (-1,-1), "MIDDLE"),
127
+ ]))
128
+ story.append(table)
129
+ doc.build(story)
130
+ pdf_buf.seek(0)
131
+ st.download_button(
132
+ "⬇️ Baixar PDF",
133
+ data=pdf_buf,
134
+ file_name=f"{base_name}.pdf",
135
+ mime="application/pdf",
136
+ key=f"dl_pdf_{base_name}"
137
+ )
138
+ except Exception as e:
139
+ st.info(f"PDF: não foi possível gerar o arquivo (ReportLab). Detalhe: {e}")
140
+
141
+ st.markdown("</div>", unsafe_allow_html=True) # fecha dl-bar
142
+
143
+
144
+ # ======================================================
145
+ # Helpers para clientes múltiplos e Indicadores visuais
146
+ # ======================================================
147
+ def _ensure_client_exploded(df: pd.DataFrame) -> pd.DataFrame:
148
+ """
149
+ Garante 'cliente_lista' a partir de 'cliente' separando por ';' quando necessário
150
+ e retorna um DF 'explodido' (uma linha por cliente), sem perder as colunas originais.
151
+ """
152
+ if df.empty:
153
+ return df.copy()
154
+
155
+ df2 = df.copy()
156
+
157
+ if "cliente_lista" not in df2.columns and "cliente" in df2.columns:
158
+ def _split_by_semicolon(s):
159
+ if pd.isna(s):
160
+ return None
161
+ parts = [p.strip() for p in str(s).split(";")]
162
+ parts = [p for p in parts if p]
163
+ return parts or None
164
+ df2["cliente_lista"] = df2["cliente"].apply(_split_by_semicolon)
165
+
166
+ if "cliente_lista" in df2.columns:
167
+ try:
168
+ exploded = df2.explode("cliente_lista", ignore_index=True)
169
+ exploded["cliente_lista"] = exploded["cliente_lista"].astype(str).str.strip()
170
+ exploded = exploded[exploded["cliente_lista"].notna() & (exploded["cliente_lista"].str.len() > 0)]
171
+ return exploded
172
+ except Exception:
173
+ return df2
174
+ return df2
175
+
176
+ # ==============================
177
+ # ✅ NOVO: visão da Tabela (cliente único + Data/Hora + ícones)
178
+ # ==============================
179
+ def _build_table_view_unique_client(df: pd.DataFrame) -> pd.DataFrame:
180
+ """
181
+ Prepara a Tabela com:
182
+ - Cliente único por linha (explode por ';')
183
+ - RecebidoEm separado em Data e Hora
184
+ - Colunas visuais (📎, 🔔, 👁️)
185
+ """
186
+ if df.empty:
187
+ return df.copy()
188
+
189
+ base = df.copy()
190
+
191
+ # 1) Garante lista de clientes e "explode"
192
+ base = _ensure_client_exploded(base)
193
+ if "cliente_lista" not in base.columns and "cliente" in base.columns:
194
+ base["cliente_lista"] = base["cliente"].apply(
195
+ lambda s: [p.strip() for p in str(s).split(";") if p.strip()] if pd.notna(s) else None
196
+ )
197
+ try:
198
+ base = base.explode("cliente_lista", ignore_index=True)
199
+ except Exception:
200
+ pass
201
+
202
+ # Nome final do cliente para a Tabela
203
+ base["Cliente"] = base["cliente_lista"].where(base["cliente_lista"].notna(), base.get("cliente"))
204
+
205
+ # 2) Separa RecebidoEm em Data e Hora
206
+ dt = pd.to_datetime(base.get("RecebidoEm"), errors="coerce")
207
+ base["Data"] = dt.dt.strftime("%d/%m/%Y").fillna("")
208
+ base["Hora"] = dt.dt.strftime("%H:%M").fillna("")
209
+
210
+ # 3) Colunas visuais
211
+ anexos = base.get("Anexos")
212
+ if anexos is not None:
213
+ base["📎"] = anexos.fillna(0).astype(int).apply(lambda n: "📎" if n > 0 else "")
214
+ else:
215
+ base["📎"] = ""
216
+
217
+ imp = base.get("Importancia")
218
+ if imp is not None:
219
+ mapa_imp = {"2": "🔴 Alta", "1": "🟡 Normal", "0": "⚪ Baixa", 2: "🔴 Alta", 1: "🟡 Normal", 0: "⚪ Baixa"}
220
+ base["🔔"] = base["Importancia"].map(mapa_imp).fillna("")
221
+ else:
222
+ base["🔔"] = ""
223
+
224
+ if "Lido" in base.columns:
225
+ base["👁️"] = base["Lido"].apply(lambda v: "✅" if bool(v) else "⏳")
226
+ else:
227
+ base["👁️"] = ""
228
+
229
+ # 4) Alias para Placa
230
+ if "placa" in base.columns and "Placa" not in base.columns:
231
+ base["Placa"] = base["placa"]
232
+
233
+ # 5) Ordenação das colunas para leitura profissional
234
+ prefer = [
235
+ "Data", "Hora", "Cliente", "tipo", "Placa", "Assunto",
236
+ "Status_join", "portaria",
237
+ "📎", "🔔", "👁️",
238
+ "Anexos", "TamanhoKB", "Remetente",
239
+ "PastaPath", "Pasta",
240
+ ]
241
+ prefer = [c for c in prefer if c in base.columns]
242
+
243
+ drop_aux = {"cliente_lista"} # oculta auxiliar interna
244
+ others = [c for c in base.columns if c not in (set(prefer) | drop_aux | {"RecebidoEm"})]
245
+ cols = prefer + others
246
+
247
+ df_show = base[cols].copy()
248
+ return df_show
249
+
250
+ # ==============================
251
+ # Indicadores (visual + profissional)
252
+ # ==============================
253
+ import plotly.express as px
254
+
255
+ def _render_indicators_custom(df: pd.DataFrame, dt_col_name: str, cols_for_topn: list[str], topn_default: int = 10):
256
+ """
257
+ Indicadores com:
258
+ - Série temporal (gráfico)
259
+ - Top Clientes (tratando clientes múltiplos separados por ';')
260
+ - Outros Top N (Remetente, Tipo, Categoria, Status etc.)
261
+ Em layout de duas colunas para não ocupar a página inteira.
262
+ Inclui controles: Top N e Mostrar tabelas.
263
+ """
264
+ if df.empty:
265
+ st.info("Nenhum dado após filtros. Ajuste os filtros para ver indicadores.")
266
+ return
267
+
268
+ st.subheader("📊 Indicadores")
269
+
270
+ # Controles globais dos Indicadores
271
+ ctl1, ctl2, ctl3 = st.columns([1,1,2])
272
+ topn = ctl1.selectbox("Top N", [5, 10, 15, 20, 30], index=[5,10,15,20,30].index(topn_default))
273
+ show_tables = ctl2.checkbox("Mostrar tabelas abaixo dos gráficos", value=True)
274
+ palette = ctl3.selectbox("Tema de cor", ["plotly_white", "plotly", "ggplot2", "seaborn"], index=0)
275
+
276
+ # ===== 1) Série temporal =====
277
+ if dt_col_name in df.columns:
278
+ try:
279
+ _dt = pd.to_datetime(df[dt_col_name], errors="coerce")
280
+ por_dia = _dt.dt.date.value_counts().sort_index()
281
+ if not por_dia.empty:
282
+ fig_ts = px.bar(
283
+ por_dia, x=por_dia.index, y=por_dia.values,
284
+ labels={"x": "Data", "y": "Qtd"},
285
+ title="Mensagens por dia",
286
+ template=palette,
287
+ height=300,
288
+ )
289
+ fig_ts.update_layout(margin=dict(l=10, r=10, t=50, b=10))
290
+ st.plotly_chart(fig_ts, use_container_width=True)
291
+ except Exception:
292
+ pass
293
+
294
+ # ===== 2) Top Clientes =====
295
+ col_left, col_right = st.columns(2)
296
+
297
+ with col_left:
298
+ st.markdown("### 👥 Top Clientes")
299
+ df_clients = _ensure_client_exploded(df)
300
+
301
+ if "cliente_lista" in df_clients.columns:
302
+ vc_clientes = (
303
+ df_clients["cliente_lista"]
304
+ .dropna()
305
+ .astype(str)
306
+ .str.strip()
307
+ .value_counts()
308
+ .head(topn)
309
+ )
310
+ if not vc_clientes.empty:
311
+ ordered = vc_clientes.sort_values(ascending=True)
312
+ fig_cli = px.bar(
313
+ ordered,
314
+ x=ordered.values, y=ordered.index, orientation="h",
315
+ labels={"x": "Qtd", "y": "Cliente"},
316
+ title=f"Top {topn} Clientes",
317
+ template=palette, height=420,
318
+ )
319
+ fig_cli.update_layout(margin=dict(l=10, r=10, t=50, b=10))
320
+ st.plotly_chart(fig_cli, use_container_width=True)
321
+
322
+ if show_tables:
323
+ st.dataframe(
324
+ vc_clientes.rename("Qtd").to_frame(),
325
+ use_container_width=True,
326
+ height=300
327
+ )
328
+ else:
329
+ st.info("Não há clientes para exibir.")
330
+ else:
331
+ st.info("Coluna de clientes não disponível para indicador.")
332
+
333
+ # ===== 3) Outros Top N =====
334
+ with col_right:
335
+ st.markdown("### 🏆 Outros Top N")
336
+ ignore_cols = {"cliente", "cliente_lista"}
337
+ cols_others = [c for c in (cols_for_topn or []) if c in df.columns and c not in ignore_cols]
338
+
339
+ if not cols_others:
340
+ fallback_cols = [c for c in ["Remetente", "tipo", "Categoria", "Status_join", "Pasta", "PastaPath"] if c in df.columns]
341
+ cols_others = fallback_cols[:3] # até 3 por padrão
342
+
343
+ for col in cols_others[:4]:
344
+ try:
345
+ st.markdown(f"**Top {topn} por `{col}`**")
346
+ if df[col].apply(lambda x: isinstance(x, list)).any():
347
+ exploded = df.explode(col)
348
+ serie = exploded[col].dropna().astype(str).str.strip().value_counts().head(topn)
349
+ else:
350
+ serie = df[col].dropna().astype(str).str.strip().value_counts().head(topn)
351
+
352
+ if serie is None or serie.empty:
353
+ st.caption("Sem dados nesta coluna.")
354
+ continue
355
+
356
+ ordered = serie.sort_values(ascending=True)
357
+ fig_any = px.bar(
358
+ ordered,
359
+ x=ordered.values, y=ordered.index, orientation="h",
360
+ labels={"x": "Qtd", "y": col},
361
+ template=palette, height=260,
362
+ )
363
+ fig_any.update_layout(margin=dict(l=10, r=10, t=30, b=10))
364
+ st.plotly_chart(fig_any, use_container_width=True)
365
+
366
+ if show_tables:
367
+ st.dataframe(
368
+ serie.rename("Qtd").to_frame(),
369
+ use_container_width=True,
370
+ height=220,
371
+ )
372
+ except Exception as e:
373
+ st.warning(f"Não foi possível calcular TopN para `{col}`: {e}")
374
+
375
+
376
+ # ==============================
377
+ # Cards/KPIs — clientes, veículos e notas
378
+ # ==============================
379
+ def _count_notes(row) -> int:
380
+ """Conta notas em uma linha (lista em 'nota_fiscal' ou string)."""
381
+ v = row.get("nota_fiscal")
382
+ if isinstance(v, list):
383
+ return len([x for x in v if str(x).strip()])
384
+ if isinstance(v, str) and v.strip():
385
+ parts = [p for p in re.split(r"[^\d]+", v) if p.strip()]
386
+ return len(parts)
387
+ return 0
388
+
389
+ def _render_kpis(df: pd.DataFrame):
390
+ """Renderiza cards de KPIs e tabelas de resumo por cliente, incluindo tipos de operação."""
391
+ if df.empty:
392
+ return
393
+
394
+ # Preferimos 'cliente_lista' para múltiplos; caso contrário, 'cliente'
395
+ cliente_col = "cliente_lista" if "cliente_lista" in df.columns else ("cliente" if "cliente" in df.columns else None)
396
+ tipo_col = "tipo" if "tipo" in df.columns else None
397
+ placa_col = "placa" if "placa" in df.columns else ("Placa" if "Placa" in df.columns else None)
398
+
399
+ _df = df.copy()
400
+ _df["__notes_count__"] = _df.apply(_count_notes, axis=1)
401
+
402
+ # Se for lista, vamos explodir para análises por cliente
403
+ _df_exploded = None
404
+ if cliente_col == "cliente_lista":
405
+ try:
406
+ _df_exploded = _df.explode("cliente_lista")
407
+ _df_exploded["cliente_lista"] = _df_exploded["cliente_lista"].astype(str).str.strip()
408
+ except Exception:
409
+ _df_exploded = None
410
+
411
+ # Clientes únicos
412
+ if cliente_col == "cliente_lista" and _df_exploded is not None:
413
+ clientes_unicos = _df_exploded["cliente_lista"].dropna().astype(str).str.strip().nunique()
414
+ elif cliente_col:
415
+ clientes_unicos = _df[cliente_col].dropna().astype(str).str.strip().nunique()
416
+ else:
417
+ clientes_unicos = 0
418
+
419
+ # Placas únicas
420
+ placas_unicas = _df[placa_col].dropna().nunique() if placa_col else 0
421
+
422
+ # Total de notas
423
+ notas_total = int(_df["__notes_count__"].sum())
424
+
425
+ st.markdown('<div class="kpi-wrap">', unsafe_allow_html=True)
426
+ st.markdown(
427
+ f"""
428
+ <div class="kpi-card">
429
+ <div class="kpi-top"><div class="kpi-ico">👥</div><div class="kpi-title">Clientes (únicos)</div></div>
430
+ <div class="kpi-val">{clientes_unicos}</div>
431
+ <div class="kpi-note">Número total de clientes distintos no período.</div>
432
+ </div>
433
+ """,
434
+ unsafe_allow_html=True,
435
+ )
436
+ st.markdown(
437
+ f"""
438
+ <div class="kpi-card">
439
+ <div class="kpi-top"><div class="kpi-ico">🚚</div><div class="kpi-title">Placas (veículos únicos)</div></div>
440
+ <div class="kpi-val">{placas_unicas}</div>
441
+ <div class="kpi-note">Contagem de veículos distintos identificados.</div>
442
+ </div>
443
+ """,
444
+ unsafe_allow_html=True,
445
+ )
446
+ st.markdown(
447
+ f"""
448
+ <div class="kpi-card">
449
+ <div class="kpi-top"><div class="kpi-ico">🧾</div><div class="kpi-title">Notas (total)</div></div>
450
+ <div class="kpi-val">{notas_total}</div>
451
+ <div class="kpi-note">Soma de notas fiscais informadas nos e-mails.</div>
452
+ </div>
453
+ """,
454
+ unsafe_allow_html=True,
455
+ )
456
+ st.markdown("</div>", unsafe_allow_html=True)
457
+
458
+ try:
459
+ media_placas_por_cliente = (placas_unicas / clientes_unicos) if clientes_unicos else 0
460
+ media_notas_por_cliente = (notas_total / clientes_unicos) if clientes_unicos else 0
461
+ st.caption(
462
+ f"📌 Média de **placas por cliente**: {media_placas_por_cliente:.2f} • "
463
+ f"Média de **notas por cliente**: {media_notas_por_cliente:.2f}"
464
+ )
465
+ except Exception:
466
+ pass
467
+
468
+ with st.expander("📒 Detalhes por cliente (resumo)", expanded=False):
469
+ # ✅ Operações por cliente e tipo
470
+ if cliente_col and tipo_col:
471
+ st.write("**Operações por cliente (contagem por tipo)**")
472
+ if cliente_col == "cliente_lista" and _df_exploded is not None:
473
+ operacoes_por_cliente = (
474
+ _df_exploded.dropna(subset=["cliente_lista", tipo_col])
475
+ .groupby(["cliente_lista", tipo_col])
476
+ .size()
477
+ .unstack(fill_value=0)
478
+ .sort_index()
479
+ )
480
+ else:
481
+ operacoes_por_cliente = (
482
+ _df.dropna(subset=[cliente_col, tipo_col])
483
+ .groupby([cliente_col, tipo_col])
484
+ .size()
485
+ .unstack(fill_value=0)
486
+ .sort_index()
487
+ )
488
+ st.dataframe(operacoes_por_cliente, use_container_width=True)
489
+
490
+ # ✅ Gráfico de barras agrupadas
491
+ st.write("📊 **Gráfico: Operações por cliente e tipo**")
492
+ try:
493
+ df_plot = operacoes_por_cliente.reset_index().melt(
494
+ id_vars=(["cliente_lista"] if cliente_col == "cliente_lista" else [cliente_col]),
495
+ var_name="Tipo", value_name="Quantidade"
496
+ )
497
+ x_col = "cliente_lista" if cliente_col == "cliente_lista" else cliente_col
498
+ fig = px.bar(
499
+ df_plot, x=x_col, y="Quantidade", color="Tipo", barmode="group",
500
+ title="Operações por Cliente e Tipo", text="Quantidade", template="plotly_white"
501
+ )
502
+ fig.update_layout(xaxis_title="Cliente", yaxis_title="Quantidade", legend_title="Tipo", height=460)
503
+ st.plotly_chart(fig, use_container_width=True)
504
+ except Exception as e:
505
+ st.warning(f"Não foi possível gerar o gráfico: {e}")
506
+ else:
507
+ st.info("Sem colunas suficientes para resumo de operações por tipo.")
508
+
509
+ # ✅ Veículos por cliente
510
+ if cliente_col and placa_col:
511
+ if cliente_col == "cliente_lista" and _df_exploded is not None:
512
+ veic_por_cliente = (
513
+ _df_exploded.dropna(subset=["cliente_lista", placa_col])
514
+ .groupby("cliente_lista")[placa_col].nunique()
515
+ .sort_values(ascending=False)
516
+ .rename("Placas_Únicas")
517
+ .to_frame()
518
+ )
519
+ else:
520
+ veic_por_cliente = (
521
+ _df.dropna(subset=[cliente_col, placa_col])
522
+ .groupby(cliente_col)[placa_col].nunique()
523
+ .sort_values(ascending=False)
524
+ .rename("Placas_Únicas")
525
+ .to_frame()
526
+ )
527
+ st.write("**Veículos (placas únicas) por cliente — Top 20**")
528
+ st.dataframe(veic_por_cliente.head(20), use_container_width=True, height=460)
529
+ else:
530
+ st.info("Sem colunas de cliente/placa suficientes para o resumo de veículos.")
531
+
532
+ # ✅ Notas por cliente
533
+ if cliente_col:
534
+ if cliente_col == "cliente_lista" and _df_exploded is not None:
535
+ notas_por_cliente = (
536
+ _df_exploded.groupby("cliente_lista")["__notes_count__"]
537
+ .sum()
538
+ .sort_values(ascending=False)
539
+ .rename("Notas_Total")
540
+ .to_frame()
541
+ )
542
+ else:
543
+ notas_por_cliente = (
544
+ _df.groupby(cliente_col)["__notes_count__"]
545
+ .sum()
546
+ .sort_values(ascending=False)
547
+ .rename("Notas_Total")
548
+ .to_frame()
549
+ )
550
+ st.write("**Notas por cliente — Top 20**")
551
+ st.dataframe(notas_por_cliente.head(20), use_container_width=True, height=460)
552
+ else:
553
+ st.info("Sem coluna de cliente para o resumo de notas.")
554
+
555
+ # ==============================
556
+ # Funções auxiliares — normalização / validação
557
+ # ==============================
558
+ def _strip_accents(s: str) -> str:
559
+ if not isinstance(s, str):
560
+ return s
561
+ return "".join(c for c in unicodedata.normalize("NFD", s) if unicodedata.category(c) != "Mn")
562
+
563
+ def _html_to_text(html: str) -> str:
564
+ if not html:
565
+ return ""
566
+ text = re.sub(r"(?is)<style.*?>.*?</style>", " ", html)
567
+ text = re.sub(r"(?is)<script.*?>.*?</script>", " ", text)
568
+ text = re.sub(r"(?is)<br\s*/?>", "\n", text)
569
+ text = re.sub(r"(?is)</p>", "\n", text)
570
+ text = re.sub(r"(?is)<.*?>", " ", text)
571
+ text = re.sub(r"[ \t]+", " ", text)
572
+ text = re.sub(r"\s*\n\s*", "\n", text).strip()
573
+ return text
574
+
575
+ def _normalize_spaces(s: str) -> str:
576
+ if not s:
577
+ return ""
578
+ s = s.replace("\r", "\n")
579
+ s = re.sub(r"[ \t]+", " ", s)
580
+ s = re.sub(r"\n{2,}", "\n", s).strip()
581
+ return s
582
+
583
+ def _normalize_person_name(s: str) -> str:
584
+ """
585
+ Converte 'Sobrenome, Nome' -> 'Nome Sobrenome'.
586
+ Mantém como está se não houver vírgula.
587
+ """
588
+ if not s:
589
+ return s
590
+ s = s.strip()
591
+ if "," in s:
592
+ partes = [p.strip() for p in s.split(",")]
593
+ if len(partes) >= 2:
594
+ return f"{partes[1]} {partes[0]}".strip()
595
+ return s
596
+
597
+ # ====== Utilitário robusto para extrair e validar PLACA ======
598
+ def _clean_plate(p: str) -> str:
599
+ """Normaliza a placa: remove espaços/hífens e deixa maiúscula."""
600
+ return re.sub(r"[^A-Za-z0-9]", "", (p or "")).upper()
601
+
602
+ def _is_valid_plate(p: str) -> bool:
603
+ """
604
+ Valida placa nos formatos:
605
+ - antigo: AAA1234
606
+ - Mercosul: AAA1B23
607
+ """
608
+ if not p:
609
+ return False
610
+ p = _clean_plate(p)
611
+ if len(p) != 7:
612
+ return False
613
+ if re.fullmatch(r"[A-Z]{3}[0-9]{4}", p): # antigo
614
+ return True
615
+ if re.fullmatch(r"[A-Z]{3}[0-9][A-Z][0-9]{2}", p): # Mercosul
616
+ return True
617
+ return False
618
+
619
+ def _format_plate(p: str) -> str:
620
+ """Formata visualmente: antigo → AAA-1234; Mercosul permanece sem hífen."""
621
+ p0 = _clean_plate(p)
622
+ if re.fullmatch(r"[A-Z]{3}[0-9]{4}", p0):
623
+ return f"{p0[:3]}-{p0[3:]}"
624
+ return p0
625
+
626
+ def _extract_plate_from_text(text: str) -> str | None:
627
+ """Extrai placa do texto com robustez."""
628
+ if not text:
629
+ return None
630
+
631
+ t = _strip_accents(text).upper()
632
+ t = re.sub(r"PLACA\s+DE\s+AUTORIZA\w+", " ", t)
633
+
634
+ # 1) Por linhas contendo 'PLACA' e NÃO 'AUTORIZA'
635
+ for ln in [x.strip() for x in t.splitlines() if x.strip()]:
636
+ if "PLACA" in ln and "AUTORIZA" not in ln:
637
+ for m in re.finditer(r"\b[A-Z0-9]{7}\b", ln):
638
+ cand = m.group(0)
639
+ if _is_valid_plate(cand):
640
+ return _format_plate(cand)
641
+ m_old = re.search(r"\b([A-Z]{3})[ -]?([0-9]{4})\b", ln)
642
+ if m_old:
643
+ cand = (m_old.group(1) + m_old.group(2)).upper()
644
+ if _is_valid_plate(cand):
645
+ return _format_plate(cand)
646
+ m_new = re.search(r"\b([A-Z]{3})[ -]?([0-9])[ -]?([A-Z])[ -]?([0-9]{2})\b", ln)
647
+ if m_new:
648
+ cand = (m_new.group(1) + m_new.group(2) + m_new.group(3) + m_new.group(4)).upper()
649
+ if _is_valid_plate(cand):
650
+ return _format_plate(cand)
651
+
652
+ # 2) Fallbacks
653
+ for m in re.finditer(r"\b([A-Z]{3})[ -]?([0-9]{4})\b", t):
654
+ cand = (m.group(1) + m.group(2)).upper()
655
+ if _is_valid_plate(cand):
656
+ return _format_plate(cand)
657
+ for m in re.finditer(r"\b([A-Z]{3})[ -]?([0-9])[ -]?([A-Z])[ -]?([0-9]{2})\b", t):
658
+ cand = (m.group(1) + m.group(2) + m.group(3) + m.group(4)).upper()
659
+ if _is_valid_plate(cand):
660
+ return _format_plate(cand)
661
+
662
+ return None
663
+
664
+ # ====== Sanitização de CLIENTE ======
665
+ def _sanitize_cliente(s: str | None) -> str | None:
666
+ if not s:
667
+ return s
668
+ s2 = re.sub(r"\s*:\s*", " ", s)
669
+ s2 = re.sub(r"\s+", " ", s2).strip(" -:|")
670
+ return s2.strip()
671
+
672
+ def _split_semicolon(s: str | None) -> list[str] | None:
673
+ if not s:
674
+ return None
675
+ parts = [p.strip(" -:|").strip() for p in s.split(";")]
676
+ parts = [p for p in parts if p]
677
+ return parts or None
678
+
679
+ def _cliente_to_list(cliente_raw: str | None) -> list[str] | None:
680
+ s = _sanitize_cliente(cliente_raw)
681
+ return _split_semicolon(s)
682
+
683
+ # ==============================
684
+ # Parser do corpo do e‑mail (modelo padrão)
685
+ # ==============================
686
+ def _parse_email_body(raw_text: str) -> dict:
687
+ """Extrai campos estruturados do e‑mail padrão."""
688
+ text = _normalize_spaces(raw_text or "")
689
+ text_acc = _strip_accents(text)
690
+
691
+ def get(pat, acc=False, flags=re.IGNORECASE | re.MULTILINE):
692
+ return (re.search(pat, text_acc if acc else text, flags) or re.search(pat, text, flags))
693
+
694
+ data = {}
695
+ m = get(r"\bItem\s+ID\s*([0-9]+)")
696
+ data["item_id"] = m.group(1) if m else None
697
+
698
+ m = get(r"Portaria\s*-\s*([^\n]+)")
699
+ data["portaria"] = (m.group(1).strip() if m else None)
700
+
701
+ m = get(r"\b([0-9]{2}/[0-9]{2}/[0-9]{4})\s+([0-9]{2}:[0-9]{2})\b")
702
+ data["timestamp_corpo_data"] = m.group(1) if m else None
703
+ data["timestamp_corpo_hora"] = m.group(2) if m else None
704
+
705
+ m = get(r"NOTA\s+FISCAL\s*([0-9/\- ]+)")
706
+ nf = None
707
+ if m:
708
+ nf = re.sub(r"\s+", "", m.group(1))
709
+ nf = [p for p in nf.split("/") if p]
710
+ data["nota_fiscal"] = nf
711
+
712
+ m = get(r"TIPO\s*([A-ZÁÂÃÉÍÓÚÇ ]+)")
713
+ data["tipo"] = (m.group(1).strip() if m else None)
714
+
715
+ m = get(r"N[º°]?\s*DA\s*PLACA\s*DE\s*AUTORIZA[ÇC]AO\s*([0-9]+)", acc=True)
716
+ data["num_placa_autorizacao"] = m.group(1) if m else None
717
+
718
+ # CLIENTE — ":" opcional
719
+ m = get(r"CLIENTE\s*:?\s*([^\n]+)")
720
+ cliente_raw = (m.group(1).strip() if m else None)
721
+ data["cliente"] = _sanitize_cliente(cliente_raw)
722
+ data["cliente_lista"] = _cliente_to_list(cliente_raw)
723
+
724
+ # STATUS — ":" opcional; separa por múltiplos espaços/tab
725
+ m = get(r"STATUS\s*:?\s*([^\n]+)")
726
+ status_raw = m.group(1).strip() if m else None
727
+ data["status_lista"] = [p.strip() for p in re.split(r"\s{2,}|\t", status_raw) if p.strip()] if status_raw else None
728
+
729
+ # Liberações (ENTRADA/SAÍDA)
730
+ m = get(r"LIBERA[ÇC][AÃ]O\s*DE\s*ENTRADA\s*([^\n]*)", acc=True)
731
+ liber_entr = (m.group(1).strip() or None) if m else None
732
+ data["liberacao_entrada"] = liber_entr
733
+ data["liberacao_entrada_responsavel"] = _normalize_person_name(liber_entr) if liber_entr else None
734
+ data["liberacao_entrada_flag"] = bool(liber_entr)
735
+
736
+ m = get(r"LIBERA[ÇC][AÃ]O\s*DE\s*SA[IÍ]DA\s*([^\n]*)", acc=True)
737
+ liber_saida = (m.group(1).strip() or None) if m else None
738
+ data["liberacao_saida"] = liber_saida
739
+ data["liberacao_saida_responsavel"] = _normalize_person_name(liber_saida) if liber_saida else None
740
+ data["liberacao_saida_flag"] = bool(liber_saida)
741
+
742
+ m = get(r"RG\s*ou\s*CPF\s*do\s*MOTORISTA\s*([0-9.\-]+)")
743
+ data["rg_cpf_motorista"] = m.group(1) if m else None
744
+
745
+ # Placa do veículo
746
+ data["placa"] = _extract_plate_from_text(text)
747
+
748
+ # Horários
749
+ m = get(r"HR\s*ENTRADA\s*:?\s*([0-9]{2}:[0-9]{2})")
750
+ data["hr_entrada"] = m.group(1) if m else None
751
+
752
+ m = get(r"HR\s*SA[IÍ]DA\s*:?\s*([0-9]{2}:[0-9]{2})", acc=True)
753
+ data["hr_saida"] = m.group(1) if m else None
754
+ edit_saida = get(r"HR\s*SA[IÍ]DA.*?\bEditado\b", acc=True, flags=re.IGNORECASE | re.DOTALL)
755
+ data["hr_saida_editado"] = bool(edit_saida)
756
+
757
+ m = get(r"DATA\s*DA\s*OPERA[ÇC][AÃ]O\s*([0-9]{2}/[0-9]{2}/[0-9]{4})", acc=True)
758
+ data["data_operacao"] = m.group(1) if m else None
759
+
760
+ m = get(r"Palavras[- ]chave\s*Corporativas\s*([^\n]*)", acc=True)
761
+ data["palavras_chave"] = (m.group(1).strip() or None) if m else None
762
+
763
+ m = get(r"AGENDAMENTO\s*(SIM|N[ÃA]O)", acc=True)
764
+ agend = m.group(1).upper() if m else None
765
+ data["agendamento"] = {"SIM": True, "NAO": False, "NÃO": False}.get(agend, None)
766
+
767
+ data["status_editado"] = any("editado" in p.lower() for p in (data["status_lista"] or []))
768
+ data["Status_join"] = " | ".join(data["status_lista"]) if isinstance(data.get("status_lista"), list) else (data.get("status_lista") or "")
769
+ return data
770
+
771
+ # ==============================
772
+ # Outlook Desktop — listagem / leitura
773
+ # ==============================
774
+ def _list_inbox_paths(ns) -> list[str]:
775
+ """Inbox da caixa padrão (para retrocompatibilidade)."""
776
+ paths = ["Inbox"]
777
+ try:
778
+ olFolderInbox = 6
779
+ inbox = ns.GetDefaultFolder(olFolderInbox)
780
+ except Exception:
781
+ return []
782
+
783
+ def _walk(folder, prefix="Inbox"):
784
+ try:
785
+ for i in range(1, folder.Folders.Count + 1):
786
+ f = folder.Folders.Item(i)
787
+ full_path = prefix + "\\" + f.Name
788
+ paths.append(full_path)
789
+ _walk(f, full_path)
790
+ except Exception:
791
+ pass
792
+
793
+ _walk(inbox, "Inbox")
794
+ return paths
795
+
796
+ def _list_all_inbox_paths(ns) -> list[str]:
797
+ """Lista caminhos de Inbox para TODAS as stores (caixas), ex.: 'Minha Caixa\\Inbox\\Sub'."""
798
+ paths: list[str] = []
799
+ olFolderInbox = 6
800
+ try:
801
+ for i in range(1, ns.Folders.Count + 1):
802
+ store = ns.Folders.Item(i)
803
+ root_name = str(store.Name).strip()
804
+ try:
805
+ inbox = store.GetDefaultFolder(olFolderInbox)
806
+ except Exception:
807
+ continue
808
+ inbox_display = str(inbox.Name).strip()
809
+ root_prefix = f"{root_name}\\{inbox_display}"
810
+ paths.append(root_prefix)
811
+
812
+ def _walk(folder, prefix):
813
+ try:
814
+ for j in range(1, folder.Folders.Count + 1):
815
+ f2 = folder.Folders.Item(j)
816
+ full_path = prefix + "\\" + f2.Name
817
+ paths.append(full_path)
818
+ _walk(f2, full_path)
819
+ except Exception:
820
+ pass
821
+
822
+ _walk(inbox, root_prefix)
823
+ except Exception:
824
+ pass
825
+ return sorted(set(paths))
826
+
827
+ def _get_folder_by_path_any_store(ns, path: str):
828
+ """
829
+ Resolve caminho de pasta começando por:
830
+ - 'Inbox\\...' (ou nome local, ex.: 'Caixa de Entrada\\...')
831
+ - 'Sent Items\\...' (ou nome local, ex.: 'Itens Enviados\\...')
832
+ - 'NomeDaStore\\Inbox\\...' OU 'NomeDaStore\\<NomeLocalInbox>\\...'
833
+ - 'NomeDaStore\\Sent Items\\...' OU 'NomeDaStore\\<NomeLocalSent>\\...'
834
+ Faz match case-insensitive nas subpastas.
835
+ """
836
+ OL_INBOX = 6
837
+ OL_SENT = 5
838
+
839
+ p = (path or "").strip().replace("/", "\\")
840
+ parts = [x for x in p.split("\\") if x]
841
+ if not parts:
842
+ raise RuntimeError("Caminho da pasta vazio. Ex.: Inbox\\Financeiro, Sent Items\\Faturamento ou Minha Caixa\\Inbox\\Operacional")
843
+
844
+ # Nomes locais (ex.: PT-BR)
845
+ try:
846
+ inbox_local = ns.GetDefaultFolder(OL_INBOX).Name
847
+ except Exception:
848
+ inbox_local = "Inbox"
849
+ try:
850
+ sent_local = ns.GetDefaultFolder(OL_SENT).Name
851
+ except Exception:
852
+ sent_local = "Sent Items"
853
+
854
+ inbox_names = {"inbox", str(inbox_local).strip().lower()}
855
+ sent_names = {"sent items", str(sent_local).strip().lower()}
856
+
857
+ def _descend_from(root_folder, segs):
858
+ folder = root_folder
859
+ for seg in segs:
860
+ target = seg.strip().lower()
861
+ found = None
862
+ for i in range(1, folder.Folders.Count + 1):
863
+ cand = folder.Folders.Item(i)
864
+ if str(cand.Name).strip().lower() == target:
865
+ found = cand
866
+ break
867
+ if not found:
868
+ raise RuntimeError(f"Subpasta não encontrada: '{seg}' em '{folder.Name}'")
869
+ folder = found
870
+ return folder
871
+
872
+ # 1) Tenta como 'Store\\(Inbox|Sent Items)\\...'
873
+ if len(parts) >= 2:
874
+ store_name_candidate = parts[0].strip().lower()
875
+ for i in range(1, ns.Folders.Count + 1):
876
+ store = ns.Folders.Item(i)
877
+ if str(store.Name).strip().lower() == store_name_candidate:
878
+ first_seg = parts[1].strip().lower()
879
+ if first_seg in inbox_names:
880
+ root = store.GetDefaultFolder(OL_INBOX)
881
+ return _descend_from(root, parts[2:])
882
+ if first_seg in sent_names:
883
+ root = store.GetDefaultFolder(OL_SENT)
884
+ return _descend_from(root, parts[2:])
885
+ raise RuntimeError(
886
+ f"Segundo segmento deve ser 'Inbox'/'{inbox_local}' ou 'Sent Items'/'{sent_local}' para a store '{store.Name}'."
887
+ )
888
+
889
+ # 2) Caso contrário, usa a store padrão (Inbox ou Sent Items conforme o primeiro segmento)
890
+ first = parts[0].strip().lower()
891
+ if first in inbox_names:
892
+ root = ns.GetDefaultFolder(OL_INBOX)
893
+ return _descend_from(root, parts[1:])
894
+ if first in sent_names:
895
+ root = ns.GetDefaultFolder(OL_SENT)
896
+ return _descend_from(root, parts[1:])
897
+
898
+ raise RuntimeError(
899
+ f"O caminho deve começar por 'Inbox' (ou '{inbox_local}') ou 'Sent Items' (ou '{sent_local}'), "
900
+ f"ou por 'NomeDaStore\\Inbox' / 'NomeDaStore\\Sent Items'."
901
+ )
902
+
903
+ def _read_folder_dataframe(folder, path: str, dias: int, filtro_remetente: str, extrair_campos: bool) -> pd.DataFrame:
904
+ """
905
+ Lê e-mails de uma pasta específica e retorna DataFrame.
906
+ ✅ Fallback: se Restrict retornar 0 itens, volta para iteração completa + filtro por data.
907
+ """
908
+ items = folder.Items
909
+ items.Sort("[ReceivedTime]", True) # decrescente
910
+ cutoff = datetime.now() - timedelta(days=dias)
911
+
912
+ iter_items = items
913
+ restricted_ok = False
914
+ restricted_count = None
915
+ try:
916
+ # Formato recomendado pelo Outlook MAPI: US locale "mm/dd/yyyy hh:mm AM/PM"
917
+ dt_from = cutoff.strftime("%m/%d/%Y %I:%M %p")
918
+ iter_items = items.Restrict(f"[ReceivedTime] >= '{dt_from}'")
919
+ restricted_ok = True
920
+ try:
921
+ restricted_count = iter_items.Count
922
+ except Exception:
923
+ restricted_count = None
924
+ except Exception:
925
+ restricted_ok = False
926
+
927
+ # ✅ Se Restrict "funcionou" mas não retornou nada, aplica fallback
928
+ if restricted_ok and (restricted_count is None or restricted_count == 0):
929
+ iter_items = items
930
+ restricted_ok = False
931
+
932
+ rows = []
933
+ total_lidos = 0
934
+ for mail in iter_items:
935
+ total_lidos += 1
936
+ try:
937
+ if getattr(mail, "Class", None) != 43: # 43 = MailItem
938
+ continue
939
+
940
+ # Filtro manual de data quando Restrict não foi usado
941
+ if not restricted_ok:
942
+ try:
943
+ if getattr(mail, "ReceivedTime", None) and mail.ReceivedTime < cutoff:
944
+ continue
945
+ except Exception:
946
+ pass
947
+
948
+ try:
949
+ sender = mail.SenderEmailAddress or mail.Sender.Name
950
+ except Exception:
951
+ sender = getattr(mail, "SenderName", None)
952
+
953
+ if filtro_remetente and sender:
954
+ if filtro_remetente.lower() not in str(sender).lower():
955
+ continue
956
+
957
+ base = {
958
+ "Pasta": folder.Name,
959
+ "PastaPath": path,
960
+ "Assunto": mail.Subject,
961
+ "Remetente": sender,
962
+ "RecebidoEm": mail.ReceivedTime.strftime("%Y-%m-%d %H:%M"),
963
+ "Anexos": mail.Attachments.Count if hasattr(mail, "Attachments") else 0,
964
+ "TamanhoKB": round(mail.Size / 1024, 1) if hasattr(mail, "Size") else None,
965
+ "Importancia": str(getattr(mail, "Importance", "")),
966
+ "Categoria": getattr(mail, "Categories", "") or "",
967
+ "Lido": bool(getattr(mail, "UnRead", False) == False),
968
+ }
969
+
970
+ if extrair_campos:
971
+ raw_body = ""
972
+ try:
973
+ raw_body = mail.Body or ""
974
+ except Exception:
975
+ raw_body = ""
976
+ if not raw_body:
977
+ try:
978
+ raw_body = _html_to_text(mail.HTMLBody)
979
+ except Exception:
980
+ raw_body = ""
981
+
982
+ parsed = _parse_email_body(raw_body)
983
+ base.update(parsed)
984
+
985
+ try:
986
+ base["__corpo__"] = _strip_accents(raw_body)[:800]
987
+ except Exception:
988
+ base["__corpo__"] = ""
989
+
990
+ rows.append(base)
991
+ except Exception as e:
992
+ rows.append({"Pasta": folder.Name, "PastaPath": path, "Assunto": f"[ERRO] {e}"})
993
+
994
+ df = pd.DataFrame(rows)
995
+ with st.expander(f"🔎 Diagnóstico • {path}", expanded=False):
996
+ st.caption(
997
+ f"Itens enumerados: **{total_lidos}** "
998
+ f"| Restrict aplicado: **{restricted_ok}** "
999
+ f"{'| Restrict.Count=' + str(restricted_count) if restricted_count is not None else ''} "
1000
+ f"| Registros DF: **{df.shape[0]}**"
1001
+ )
1002
+ return df
1003
+
1004
+ def gerar_relatorio_outlook_desktop_multi(
1005
+ pastas: list[str],
1006
+ dias: int,
1007
+ filtro_remetente: str = "",
1008
+ extrair_campos: bool = True
1009
+ ) -> pd.DataFrame:
1010
+ """Inicializa COM, conecta ao Outlook, lê múltiplas pastas (todas as stores) e finaliza COM."""
1011
+ try:
1012
+ import win32com.client
1013
+ pythoncom.CoInitialize()
1014
+ ns = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
1015
+ except Exception as e:
1016
+ st.error(f"Falha ao conectar ao Outlook/pywin32: {e}")
1017
+ return pd.DataFrame()
1018
+
1019
+ frames = []
1020
+ try:
1021
+ for path in pastas:
1022
+ try:
1023
+ folder = _get_folder_by_path_any_store(ns, path)
1024
+ df = _read_folder_dataframe(folder, path, dias, filtro_remetente, extrair_campos)
1025
+ frames.append(df)
1026
+ except Exception as e:
1027
+ st.warning(f"Não foi possível ler a pasta '{path}': {e}")
1028
+
1029
+ return pd.concat(frames, ignore_index=True) if frames else pd.DataFrame()
1030
+ finally:
1031
+ try:
1032
+ pythoncom.CoUninitialize()
1033
+ except Exception:
1034
+ pass
1035
+
1036
+ # ==============================
1037
+ # Cache / refresh control
1038
+ # ==============================
1039
+ @st.cache_data(show_spinner=False, ttl=60)
1040
+ def _cache_outlook_df(pastas: tuple, dias: int, filtro_remetente: str, extrair_campos: bool, _v: int = 1) -> pd.DataFrame:
1041
+ """Cache com TTL para reduzir leituras do Outlook."""
1042
+ return gerar_relatorio_outlook_desktop_multi(list(pastas), dias, filtro_remetente=filtro_remetente, extrair_campos=extrair_campos)
1043
+
1044
+ def _read_outlook_fresh(pastas: list[str], dias: int, filtro_remetente: str, extrair_campos: bool) -> pd.DataFrame:
1045
+ """Leitura direta, sem cache (para 'Atualizar agora')."""
1046
+ return gerar_relatorio_outlook_desktop_multi(pastas, dias, filtro_remetente=filtro_remetente, extrair_campos=extrair_campos)
1047
+
1048
+ # ==============================
1049
+ # Filtros dinâmicos (persistentes) + aplicação manual
1050
+ # ==============================
1051
+ def _sanitize_default(options: list[str], default_list: list[str]) -> list[str]:
1052
+ if not options or not default_list:
1053
+ return []
1054
+ opts_set = set(options)
1055
+ return [v for v in default_list if v in opts_set]
1056
+
1057
+ def _build_dynamic_filters(df: pd.DataFrame):
1058
+ """
1059
+ Constrói UI de filtros e retorna (df_filtrado, cols_topn).
1060
+ """
1061
+ if df.empty:
1062
+ return df, []
1063
+
1064
+ df_f = df.copy()
1065
+ try:
1066
+ df_f["RecebidoEm_dt"] = pd.to_datetime(df_f.get("RecebidoEm"), errors="coerce")
1067
+ except Exception:
1068
+ df_f["RecebidoEm_dt"] = pd.NaT
1069
+
1070
+ total_inicial = len(df_f)
1071
+
1072
+ state_keys = {
1073
+ "f_dt_ini": "flt_dt_ini",
1074
+ "f_dt_fim": "flt_dt_fim",
1075
+ "f_lido": "flt_lido",
1076
+ "f_status": "flt_status",
1077
+ "f_saida_flag": "flt_saida_flag",
1078
+ "f_entrada_flag": "flt_entrada_flag",
1079
+ "f_anexos_equal": "flt_anexos_equal",
1080
+ "f_anexos_apply_equal": "flt_anexos_apply_equal",
1081
+ "f_tamanho_equal": "flt_tamanho_equal",
1082
+ "f_tamanho_apply_equal": "flt_tamanho_apply_equal",
1083
+ "f_assunto": "flt_assunto",
1084
+ "f_cols_topn": "flt_cols_topn",
1085
+ "f_pasta": "flt_pasta",
1086
+ "f_pasta_path": "flt_pasta_path",
1087
+ "f_portaria": "flt_portaria",
1088
+ "f_cliente": "flt_cliente",
1089
+ "f_tipo": "flt_tipo",
1090
+ "f_placa": "flt_placa",
1091
+ "f_importancia": "flt_importancia",
1092
+ "f_categoria": "flt_categoria",
1093
+ "f_resp_saida": "flt_resp_saida",
1094
+ "f_resp_entrada": "flt_resp_entrada",
1095
+ }
1096
+ for k in state_keys.values():
1097
+ st.session_state.setdefault(k, None)
1098
+
1099
+ with st.expander("🔎 Filtros do relatório (por colunas retornadas)", expanded=True):
1100
+ with st.form("form_filtros_outlook"):
1101
+ # Período
1102
+ min_dt = df_f["RecebidoEm_dt"].min()
1103
+ max_dt = df_f["RecebidoEm_dt"].max()
1104
+ if pd.notna(min_dt) and pd.notna(max_dt):
1105
+ col_d1, col_d2 = st.columns(2)
1106
+ 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")
1107
+ 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")
1108
+ else:
1109
+ dt_ini, dt_fim = None, None
1110
+
1111
+ def _multi_select(col_name, label, state_key):
1112
+ if col_name in df_f.columns:
1113
+ options = sorted([v for v in df_f[col_name].dropna().astype(str).unique() if v != ""])
1114
+ default_raw = st.session_state[state_keys[state_key]] or []
1115
+ default = _sanitize_default(options, default_raw)
1116
+ sel = st.multiselect(label, options=options, default=default, key=f"{state_key}_widget")
1117
+ st.session_state[state_keys[state_key]] = sel
1118
+
1119
+ # ✅ Pasta (caminho completo) — preferido
1120
+ _multi_select("PastaPath", "Pasta (caminho completo)", "f_pasta_path")
1121
+ # Opcional — nome curto
1122
+ _multi_select("Pasta", "Pasta (apenas nome)", "f_pasta")
1123
+
1124
+ _multi_select("portaria", "Portaria", "f_portaria")
1125
+
1126
+ # Clientes (múltiplos)
1127
+ if "cliente_lista" in df_f.columns:
1128
+ clientes_opts = sorted({
1129
+ c.strip() for lst in df_f["cliente_lista"]
1130
+ if isinstance(lst, list) for c in lst if c and str(c).strip()
1131
+ })
1132
+ default_raw = st.session_state[state_keys["f_cliente"]] or []
1133
+ default = _sanitize_default(clientes_opts, default_raw)
1134
+ cliente_sel = st.multiselect("Cliente", options=clientes_opts, default=default, key="f_cliente_widget")
1135
+ st.session_state[state_keys["f_cliente"]] = cliente_sel
1136
+ else:
1137
+ _multi_select("cliente", "Cliente", "f_cliente")
1138
+
1139
+ _multi_select("tipo", "Tipo", "f_tipo")
1140
+ _multi_select("placa", "Placa (veículo)", "f_placa")
1141
+ _multi_select("Importancia", "Importância (0=baixa,1=normal,2=alta)", "f_importancia")
1142
+ _multi_select("Categoria", "Categoria", "f_categoria")
1143
+ _multi_select("liberacao_saida_responsavel", "Responsável (Liberação de Saída)", "f_resp_saida")
1144
+ _multi_select("liberacao_entrada_responsavel", "Responsável (Liberação de Entrada)", "f_resp_entrada")
1145
+
1146
+ # Lido?
1147
+ if "Lido" in df_f.columns:
1148
+ options_lido = ["True", "False"]
1149
+ default_raw = st.session_state[state_keys["f_lido"]] or []
1150
+ default_lido = _sanitize_default(options_lido, default_raw)
1151
+ lido_sel = st.multiselect("Lido?", options=options_lido, default=default_lido, key="f_lido_widget")
1152
+ st.session_state[state_keys["f_lido"]] = lido_sel
1153
+
1154
+ # Status
1155
+ if "status_lista" in df_f.columns:
1156
+ all_status = sorted(set(s for v in df_f["status_lista"] if isinstance(v, list) for s in v))
1157
+ default_raw = st.session_state[state_keys["f_status"]] or []
1158
+ default_status = _sanitize_default(all_status, default_raw)
1159
+ status_sel = st.multiselect("Status (qualquer dos selecionados)", options=all_status, default=default_status, key="f_status_widget")
1160
+ st.session_state[state_keys["f_status"]] = status_sel
1161
+
1162
+ # Flags
1163
+ if "liberacao_saida_flag" in df_f.columns:
1164
+ opt_saida = st.selectbox("Teve Liberação de Saída?", ["(todas)", "Sim", "Não"],
1165
+ 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"]])),
1166
+ key="f_saida_flag_widget")
1167
+ st.session_state[state_keys["f_saida_flag"]] = opt_saida
1168
+ if "liberacao_entrada_flag" in df_f.columns:
1169
+ opt_entr = st.selectbox("Teve Liberação de Entrada?", ["(todas)", "Sim", "Não"],
1170
+ 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"]])),
1171
+ key="f_entrada_flag_widget")
1172
+ st.session_state[state_keys["f_entrada_flag"]] = opt_entr
1173
+
1174
+ # Numéricos
1175
+ col_n1, col_n2 = st.columns(2)
1176
+ if "Anexos" in df_f.columns:
1177
+ col_vals = df_f["Anexos"].dropna()
1178
+ if col_vals.empty:
1179
+ col_n1.info("Sem valores para **Anexos** nesta seleção.")
1180
+ else:
1181
+ min_ax = int(col_vals.min()); max_ax = int(col_vals.max())
1182
+ if min_ax == max_ax:
1183
+ col_n1.info(f"Todos os registros têm **{min_ax}** anexos.")
1184
+ eq_val = col_n1.number_input("Filtrar por Anexos = (opcional)", min_value=min_ax, max_value=max_ax,
1185
+ value=st.session_state[state_keys["f_anexos_equal"]] or min_ax, step=1, key="f_anexos_equal_widget")
1186
+ 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")
1187
+ st.session_state[state_keys["f_anexos_equal"]] = eq_val
1188
+ st.session_state[state_keys["f_anexos_apply_equal"]] = apply_eq
1189
+ else:
1190
+ rng_ax = col_n1.slider("Anexos (intervalo)", min_value=min_ax, max_value=max_ax,
1191
+ value=st.session_state.get("__rng_anexos__", (min_ax, max_ax)), key="__rng_anexos__widget")
1192
+ st.session_state["__rng_anexos__"] = rng_ax
1193
+
1194
+ if "TamanhoKB" in df_f.columns:
1195
+ col_vals = df_f["TamanhoKB"].dropna()
1196
+ if col_vals.empty:
1197
+ col_n2.info("Sem valores para **TamanhoKB** nesta seleção.")
1198
+ else:
1199
+ min_sz = float(col_vals.min()); max_sz = float(col_vals.max())
1200
+ if abs(min_sz - max_sz) < 1e-9:
1201
+ col_n2.info(f"Todos os registros têm **{min_sz:.1f} KB**.")
1202
+ eq_sz = col_n2.number_input("Filtrar por TamanhoKB = (opcional)", min_value=float(min_sz), max_value=float(max_sz),
1203
+ value=st.session_state[state_keys["f_tamanho_equal"]] or float(min_sz), step=0.1, format="%.1f", key="f_tamanho_equal_widget")
1204
+ 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")
1205
+ st.session_state[state_keys["f_tamanho_equal"]] = eq_sz
1206
+ st.session_state[state_keys["f_tamanho_apply_equal"]] = apply_sz
1207
+ else:
1208
+ rng_sz = col_n2.slider("Tamanho (KB)", min_value=float(min_sz), max_value=float(max_sz),
1209
+ value=st.session_state.get("__rng_tamanho__", (float(min_sz), float(max_sz))), key="__rng_tamanho__widget")
1210
+ st.session_state["__rng_tamanho__"] = rng_sz
1211
+
1212
+ # Texto — Assunto contém
1213
+ txt_default = st.session_state[state_keys["f_assunto"]] or ""
1214
+ txt = st.text_input("Assunto contém (opcional)", value=txt_default, key="f_assunto_widget")
1215
+ st.session_state[state_keys["f_assunto"]] = txt
1216
+
1217
+ # Indicadores — Top N
1218
+ possiveis_cols = [c for c in ["Pasta", "PastaPath", "portaria", "cliente", "cliente_lista", "tipo", "placa",
1219
+ "Importancia", "Categoria", "Remetente", "Status_join",
1220
+ "liberacao_saida_responsavel", "liberacao_entrada_responsavel"] if c in df_f.columns]
1221
+ desired_default = st.session_state[state_keys["f_cols_topn"]] or ["cliente", "Status_join"]
1222
+ default_cols = _sanitize_default(possiveis_cols, desired_default)
1223
+ cols_for_topn = st.multiselect("Colunas para indicadores (Top N)", options=possiveis_cols, default=default_cols, key="f_cols_topn_widget")
1224
+ st.session_state[state_keys["f_cols_topn"]] = cols_for_topn
1225
+
1226
+ # Botões
1227
+ col_b1, col_b2 = st.columns(2)
1228
+ aplicar = col_b1.form_submit_button("✅ Aplicar filtros")
1229
+ limpar = col_b2.form_submit_button("🧹 Limpar filtros")
1230
+
1231
+ if limpar:
1232
+ for k in state_keys.values():
1233
+ st.session_state[k] = None
1234
+ st.session_state.pop("__rng_anexos__", None)
1235
+ st.session_state.pop("__rng_tamanho__", None)
1236
+ st.info("Filtros limpos.")
1237
+ return df, []
1238
+
1239
+ if aplicar:
1240
+ # período
1241
+ if pd.notna(df_f["RecebidoEm_dt"]).any() and dt_ini and dt_fim:
1242
+ mask_dt = (df_f["RecebidoEm_dt"].dt.date >= dt_ini) & (df_f["RecebidoEm_dt"].dt.date <= dt_fim)
1243
+ df_f = df_f[mask_dt]
1244
+
1245
+ def _apply_multi(col_name, session_key):
1246
+ sel = st.session_state.get(session_key)
1247
+ nonlocal df_f
1248
+ if sel and col_name in df_f.columns:
1249
+ df_f = df_f[df_f[col_name].astype(str).isin(sel)]
1250
+
1251
+ # ✅ aplica pelo caminho completo (preciso)
1252
+ _apply_multi("PastaPath", state_keys["f_pasta_path"])
1253
+ # (opcional) nome curto
1254
+ _apply_multi("Pasta", state_keys["f_pasta"])
1255
+ _apply_multi("portaria", state_keys["f_portaria"])
1256
+ _apply_multi("tipo", state_keys["f_tipo"])
1257
+ _apply_multi("placa", state_keys["f_placa"])
1258
+ _apply_multi("Importancia", state_keys["f_importancia"])
1259
+ _apply_multi("Categoria", state_keys["f_categoria"])
1260
+ _apply_multi("liberacao_saida_responsavel", state_keys["f_resp_saida"])
1261
+ _apply_multi("liberacao_entrada_responsavel", state_keys["f_resp_entrada"])
1262
+
1263
+ # cliente (lista ou string)
1264
+ cliente_sel = st.session_state[state_keys["f_cliente"]]
1265
+ if cliente_sel:
1266
+ if "cliente_lista" in df_f.columns:
1267
+ df_f = df_f[df_f["cliente_lista"].apply(lambda lst: isinstance(lst, list) and any(c in lst for c in cliente_sel))]
1268
+ elif "cliente" in df_f.columns:
1269
+ df_f = df_f[df_f["cliente"].astype(str).isin(cliente_sel)]
1270
+
1271
+ # lido
1272
+ lido_sel = st.session_state[state_keys["f_lido"]]
1273
+ if lido_sel and "Lido" in df_f.columns:
1274
+ df_f = df_f[df_f["Lido"].astype(str).isin(lido_sel)]
1275
+
1276
+ # status
1277
+ status_sel = st.session_state[state_keys["f_status"]]
1278
+ if status_sel and "status_lista" in df_f.columns:
1279
+ df_f = df_f[df_f["status_lista"].apply(lambda lst: isinstance(lst, list) and any(s in lst for s in status_sel))]
1280
+
1281
+ # flags
1282
+ opt_saida = st.session_state[state_keys["f_saida_flag"]]
1283
+ if opt_saida in ("Sim", "Não") and "liberacao_saida_flag" in df_f.columns:
1284
+ df_f = df_f[df_f["liberacao_saida_flag"] == (opt_saida == "Sim")]
1285
+ opt_entr = st.session_state[state_keys["f_entrada_flag"]]
1286
+ if opt_entr in ("Sim", "Não") and "liberacao_entrada_flag" in df_f.columns:
1287
+ df_f = df_f[df_f["liberacao_entrada_flag"] == (opt_entr == "Sim")]
1288
+
1289
+ # numéricos
1290
+ if st.session_state.get("__rng_anexos__") and "Anexos" in df_f.columns:
1291
+ a0, a1 = st.session_state["__rng_anexos__"]
1292
+ df_f = df_f[(df_f["Anexos"] >= a0) & (df_f["Anexos"] <= a1)]
1293
+ if st.session_state.get("__rng_tamanho__") and "TamanhoKB" in df_f.columns:
1294
+ s0, s1 = st.session_state["__rng_tamanho__"]
1295
+ df_f = df_f[(df_f["TamanhoKB"] >= s0) & (df_f["TamanhoKB"] <= s1)]
1296
+
1297
+ # igualdade
1298
+ if st.session_state.get(state_keys["f_anexos_apply_equal"]) and "Anexos" in df_f.columns:
1299
+ eq_val = st.session_state.get(state_keys["f_anexos_equal"])
1300
+ if eq_val is not None:
1301
+ df_f = df_f[df_f["Anexos"] == int(eq_val)]
1302
+ if st.session_state.get(state_keys["f_tamanho_apply_equal"]) and "TamanhoKB" in df_f.columns:
1303
+ eq_sz = st.session_state.get(state_keys["f_tamanho_equal"])
1304
+ if eq_sz is not None:
1305
+ df_f = df_f[df_f["TamanhoKB"].round(1) == round(float(eq_sz), 1)]
1306
+
1307
+ # texto
1308
+ txt = st.session_state[state_keys["f_assunto"]]
1309
+ if txt and "Assunto" in df_f.columns:
1310
+ df_f = df_f[df_f["Assunto"].astype(str).str.contains(txt.strip(), case=False, na=False)]
1311
+
1312
+ cols_for_topn = st.session_state[state_keys["f_cols_topn"]] or []
1313
+
1314
+ total_final = len(df_f)
1315
+ st.caption(f"🧮 **Diagnóstico**: antes dos filtros = {total_inicial} | depois dos filtros = {total_final}")
1316
+
1317
+ return df_f, cols_for_topn
1318
+
1319
+ possiveis_cols_init = [c for c in ["Pasta", "PastaPath", "portaria", "cliente", "cliente_lista", "tipo", "placa",
1320
+ "Importancia", "Categoria", "Remetente", "Status_join",
1321
+ "liberacao_saida_responsavel", "liberacao_entrada_responsavel"] if c in df_f.columns]
1322
+ desired_default_init = st.session_state[state_keys["f_cols_topn"]] or ["cliente", "Status_join"]
1323
+ default_cols_init = _sanitize_default(possiveis_cols_init, desired_default_init)
1324
+
1325
+ st.caption(f"🧮 **Diagnóstico**: registros disponíveis (sem aplicar filtros) = {total_inicial}")
1326
+ return df, default_cols_init
1327
+
1328
+ # ==============================
1329
+ # UI — módulo (com buffer/caching + UX simplificada)
1330
+ # ==============================
1331
+ def main():
1332
+ st.title("📧 Relatório • Outlook Desktop (Estruturado)")
1333
+ st.caption("Escolha a Caixa (Entrada ou Itens Enviados), defina o período e extraia campos estruturados do e‑mail.")
1334
+ st.markdown(_STYLES, unsafe_allow_html=True)
1335
+
1336
+ # Buffer de dados
1337
+ st.session_state.setdefault("__outlook_df__", None)
1338
+ st.session_state.setdefault("__outlook_meta__", None)
1339
+
1340
+ with st.expander("⚙️ Configurações", expanded=True):
1341
+ with st.form("form_execucao_outlook"):
1342
+ col_a, col_b, col_c = st.columns([1, 1, 1])
1343
+ dias = col_a.slider("Período (últimos N dias)", min_value=1, max_value=365, value=30)
1344
+ filtro_remetente = col_b.text_input("Filtrar por remetente (opcional)", value="", placeholder='Ex.: "@fornecedor.com" ou "Fulano"')
1345
+ extrair_campos = col_c.checkbox("Extrair campos do e‑mail (modelo padrão)", value=True)
1346
+
1347
+ # Seletor simplificado
1348
+ tipo_pasta = st.selectbox(
1349
+ "Tipo de Caixa:",
1350
+ ["📥 Caixa de Entrada", "📤 Itens Enviados"],
1351
+ help="Escolha se deseja ler a Caixa de Entrada ou os Itens Enviados."
1352
+ )
1353
+
1354
+ mapa_caminho = {
1355
+ "📥 Caixa de Entrada": "Inbox",
1356
+ "📤 Itens Enviados": "Sent Items"
1357
+ }
1358
+ pasta_base = mapa_caminho[tipo_pasta]
1359
+
1360
+ subpasta_manual = st.text_input(
1361
+ "Subpasta (opcional)",
1362
+ value="",
1363
+ placeholder="Ex.: Financeiro\\Operacional"
1364
+ )
1365
+
1366
+ if subpasta_manual.strip():
1367
+ pasta_final = f"{pasta_base}\\{subpasta_manual.strip()}"
1368
+ else:
1369
+ pasta_final = pasta_base
1370
+
1371
+ pastas_escolhidas = [pasta_final]
1372
+
1373
+ # Botões
1374
+ col_btn1, col_btn2, col_btn3, col_btn4 = st.columns([1, 1, 1, 1])
1375
+ submit_cache = col_btn1.form_submit_button("🔍 Gerar (com cache)")
1376
+ submit_fresh = col_btn2.form_submit_button("⚡ Atualizar agora (sem cache)")
1377
+ submit_clear = col_btn3.form_submit_button("🧹 Limpar cache")
1378
+ submit_test = col_btn4.form_submit_button("🧪 Teste de conexão")
1379
+
1380
+ # Ações pós-submit
1381
+ if submit_clear:
1382
+ try:
1383
+ _cache_outlook_df.clear()
1384
+ except Exception:
1385
+ try:
1386
+ st.cache_data.clear()
1387
+ except Exception:
1388
+ pass
1389
+ st.session_state["__outlook_df__"] = None
1390
+ st.session_state["__outlook_meta__"] = None
1391
+ st.success("Cache limpo. Gere novamente os dados.")
1392
+
1393
+ if submit_test:
1394
+ try:
1395
+ import win32com.client
1396
+ pythoncom.CoInitialize()
1397
+ ns = win32com.client.Dispatch("Outlook.Application").GetNamespace("MAPI")
1398
+ stores = ns.Folders.Count
1399
+ inbox_def = ns.GetDefaultFolder(6)
1400
+ sent_def = ns.GetDefaultFolder(5)
1401
+ st.info(f"Stores detectadas: **{stores}** | Inbox padrão: **{inbox_def.Name}** | Sent: **{sent_def.Name}**")
1402
+ if pastas_escolhidas:
1403
+ for p in pastas_escolhidas:
1404
+ try:
1405
+ f = _get_folder_by_path_any_store(ns, p)
1406
+ st.write(f"📁 {p} → '{f.Name}' itens: {f.Items.Count}")
1407
+ except Exception as e:
1408
+ st.warning(f"[Teste] Falha ao acessar '{p}': {e}")
1409
+ except Exception as e:
1410
+ st.error(f"[Teste] Erro ao conectar: {e}")
1411
+ finally:
1412
+ try:
1413
+ pythoncom.CoUninitialize()
1414
+ except Exception:
1415
+ pass
1416
+
1417
+ if submit_fresh:
1418
+ if not pastas_escolhidas:
1419
+ st.error("Selecione ao menos uma pasta.")
1420
+ else:
1421
+ with st.spinner("Lendo e-mails do Outlook (sem cache)..."):
1422
+ df_fresh = _read_outlook_fresh(pastas_escolhidas, dias, filtro_remetente, extrair_campos)
1423
+ st.session_state["__outlook_df__"] = df_fresh
1424
+ st.session_state["__outlook_meta__"] = {
1425
+ "pastas": list(pastas_escolhidas),
1426
+ "dias": dias,
1427
+ "filtro_remetente": filtro_remetente,
1428
+ "extrair_campos": extrair_campos,
1429
+ "loaded_at": datetime.now().strftime("%d/%m/%Y %H:%M"),
1430
+ "source": "fresh"
1431
+ }
1432
+
1433
+ if submit_cache:
1434
+ if not pastas_escolhidas:
1435
+ st.error("Selecione ao menos uma pasta.")
1436
+ else:
1437
+ with st.spinner("Lendo e-mails do Outlook (com cache)..."):
1438
+ df = _cache_outlook_df(tuple(pastas_escolhidas), dias, filtro_remetente, extrair_campos, _v=1)
1439
+ st.session_state["__outlook_df__"] = df
1440
+ st.session_state["__outlook_meta__"] = {
1441
+ "pastas": list(pastas_escolhidas),
1442
+ "dias": dias,
1443
+ "filtro_remetente": filtro_remetente,
1444
+ "extrair_campos": extrair_campos,
1445
+ "loaded_at": datetime.now().strftime("%d/%m/%Y %H:%M"),
1446
+ "source": "cache"
1447
+ }
1448
+
1449
+ # Usa o buffer se disponível
1450
+ df_src = st.session_state.get("__outlook_df__")
1451
+ meta = st.session_state.get("__outlook_meta__")
1452
+
1453
+ if isinstance(df_src, pd.DataFrame):
1454
+ origem = (meta or {}).get("source", "cache/buffer")
1455
+ if df_src.empty:
1456
+ st.info("Nenhum e-mail encontrado para os parâmetros informados. Use o botão 🧪 Teste de conexão para diagnosticar.")
1457
+ return
1458
+
1459
+ # 🎛️ Barra de Status
1460
+ pastas_lbl = ", ".join(meta.get("pastas") or [])
1461
+ extra_flag = "SIM" if meta.get("extrair_campos") else "NÃO"
1462
+ st.markdown(
1463
+ f"""
1464
+ <div class="status-bar">
1465
+ <div class="status-line">
1466
+ <div class="badge">📂 Pastas: <strong>{pastas_lbl}</strong></div>
1467
+ <div class="badge gray">🗓️ Dias: <strong>{meta.get('dias')}</strong></div>
1468
+ <div class="badge green">🔍 Origem: <strong>{origem}</strong></div>
1469
+ <div class="badge amber">🧩 Extração: <strong>{extra_flag}</strong></div>
1470
+ <div class="badge gray">⏱️ Em: <strong>{meta.get('loaded_at')}</strong></div>
1471
+ </div>
1472
+ </div>
1473
+ """, unsafe_allow_html=True
1474
+ )
1475
+
1476
+ # Filtros dinâmicos
1477
+ df_filtrado, cols_for_topn = _build_dynamic_filters(df_src)
1478
+
1479
+ # Alias visual para colunas úteis
1480
+ if "placa" in df_filtrado.columns and "Placa" not in df_filtrado.columns:
1481
+ df_filtrado = df_filtrado.copy()
1482
+ df_filtrado["Placa"] = df_filtrado["placa"]
1483
+ if "cliente_lista" in df_filtrado.columns and "ClienteLista" not in df_filtrado.columns:
1484
+ df_filtrado = df_filtrado.copy()
1485
+ df_filtrado["ClienteLista"] = df_filtrado["cliente_lista"].apply(lambda lst: "; ".join(lst) if isinstance(lst, list) else "")
1486
+
1487
+ # 🔖 Abas de visualização
1488
+ tab_geral, tab_tabela, tab_kpis, tab_inds, tab_diag = st.tabs(
1489
+ ["🧭 Visão Geral", "📄 Tabela", "📈 KPIs & Gráficos", "🏆 Indicadores (Top N)", "🛠️ Diagnóstico"]
1490
+ )
1491
+
1492
+ with tab_geral:
1493
+ # Mini-resumo
1494
+ qtd = len(df_filtrado)
1495
+ 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
1496
+ dt_min = pd.to_datetime(df_filtrado["RecebidoEm"], errors="coerce").min()
1497
+ dt_max = pd.to_datetime(df_filtrado["RecebidoEm"], errors="coerce").max()
1498
+ st.write(f"**Registros após filtros:** {qtd} "
1499
+ + (f"• **Pastas (únicas)**: {unicos_pastas}" if unicos_pastas is not None else "")
1500
+ + (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 "")
1501
+ )
1502
+
1503
+ # Série temporal simples
1504
+ if "RecebidoEm" in df_filtrado.columns:
1505
+ try:
1506
+ dts = pd.to_datetime(df_filtrado["RecebidoEm"], errors="coerce")
1507
+ series = dts.dt.date.value_counts().sort_index()
1508
+ fig = px.bar(series, x=series.index, y=series.values, labels={"x":"Data", "y":"Qtd"},
1509
+ title="Mensagens por dia", template="plotly_white")
1510
+ fig.update_layout(height=360)
1511
+ st.plotly_chart(fig, use_container_width=True)
1512
+ except Exception:
1513
+ pass
1514
+
1515
+ # ==============================
1516
+ # ✅ ABA TABELA — Cliente único + Data/Hora + ícones
1517
+ # ==============================
1518
+ with tab_tabela:
1519
+ df_show = _build_table_view_unique_client(df_filtrado)
1520
+
1521
+ # 🎛️ Configurações de colunas (tipos e rótulos)
1522
+ colcfg = {}
1523
+
1524
+ # Data e Hora: tenta usar tipos nativos; fallback para texto
1525
+ if "Data" in df_show.columns:
1526
+ try:
1527
+ colcfg["Data"] = st.column_config.DateColumn("Data", format="DD/MM/YYYY")
1528
+ except Exception:
1529
+ colcfg["Data"] = st.column_config.TextColumn("Data")
1530
+
1531
+ if "Hora" in df_show.columns:
1532
+ # Alguns builds do Streamlit não possuem TimeColumn — fallback automático
1533
+ try:
1534
+ colcfg["Hora"] = st.column_config.TimeColumn("Hora", format="HH:mm")
1535
+ except Exception:
1536
+ colcfg["Hora"] = st.column_config.TextColumn("Hora")
1537
+
1538
+ if "Cliente" in df_show.columns:
1539
+ colcfg["Cliente"] = st.column_config.TextColumn("Cliente")
1540
+
1541
+ if "Placa" in df_show.columns:
1542
+ colcfg["Placa"] = st.column_config.TextColumn("Placa")
1543
+
1544
+ if "tipo" in df_show.columns:
1545
+ colcfg["tipo"] = st.column_config.TextColumn("Tipo")
1546
+
1547
+ if "Status_join" in df_show.columns:
1548
+ colcfg["Status_join"] = st.column_config.TextColumn("Status")
1549
+
1550
+ if "Anexos" in df_show.columns:
1551
+ colcfg["Anexos"] = st.column_config.NumberColumn("Anexos", format="%d")
1552
+
1553
+ if "TamanhoKB" in df_show.columns:
1554
+ colcfg["TamanhoKB"] = st.column_config.NumberColumn("Tamanho (KB)", format="%.1f")
1555
+
1556
+ if "Remetente" in df_show.columns:
1557
+ colcfg["Remetente"] = st.column_config.TextColumn("Remetente")
1558
+
1559
+ if "PastaPath" in df_show.columns:
1560
+ colcfg["PastaPath"] = st.column_config.TextColumn("Caminho da Pasta")
1561
+
1562
+ if "Pasta" in df_show.columns:
1563
+ colcfg["Pasta"] = st.column_config.TextColumn("Pasta")
1564
+
1565
+ if "portaria" in df_show.columns:
1566
+ colcfg["portaria"] = st.column_config.TextColumn("Portaria")
1567
+
1568
+ # Colunas visuais
1569
+ if "📎" in df_show.columns:
1570
+ colcfg["📎"] = st.column_config.TextColumn("Anexo")
1571
+ if "🔔" in df_show.columns:
1572
+ colcfg["🔔"] = st.column_config.TextColumn("Importância")
1573
+ if "👁️" in df_show.columns:
1574
+ colcfg["👁️"] = st.column_config.TextColumn("Lido")
1575
+
1576
+ st.dataframe(
1577
+ df_show,
1578
+ use_container_width=True,
1579
+ hide_index=True,
1580
+ column_config=colcfg,
1581
+ height=560
1582
+ )
1583
+
1584
+ st.caption("🔎 A Tabela está em visão **por cliente** (uma linha por cliente) e com **Data** e **Hora** separadas.")
1585
+
1586
+ with tab_kpis:
1587
+ _render_kpis(df_filtrado)
1588
+
1589
+ with tab_inds:
1590
+ _render_indicators_custom(df_filtrado, dt_col_name="RecebidoEm", cols_for_topn=cols_for_topn, topn_default=10)
1591
+
1592
+ with tab_diag:
1593
+ st.write("**Colunas disponíveis**:", list(df_src.columns))
1594
+ st.write("**Linhas (antes filtros)**:", len(df_src))
1595
+ st.write("**Linhas (após filtros)**:", len(df_filtrado))
1596
+ if "PastaPath" in df_src.columns:
1597
+ st.write("**Pastas distintas**:", df_src["PastaPath"].nunique())
1598
+ if "__corpo__" in df_src.columns:
1599
+ 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)
1600
+
1601
+ # ==============================
1602
+ # ✅ Downloads — exporta a mesma visão da Tabela (explodida + Data/Hora)
1603
+ # ==============================
1604
+ base_name = f"relatorio_outlook_desktop_{date.today()}"
1605
+ df_to_export = _build_table_view_unique_client(df_filtrado)
1606
+ _build_downloads(df_to_export, base_name=base_name)
1607
+
1608
+ # Auditoria
1609
+ if _HAS_AUDIT and meta:
1610
+ try:
1611
+ registrar_log(
1612
+ usuario=st.session_state.get("usuario"),
1613
+ acao=f"Relatório Outlook (pastas={len(meta['pastas'])}, dias={meta['dias']}, extrair={origem}) — filtros aplicados",
1614
+ tabela="outlook_relatorio",
1615
+ registro_id=None
1616
+ )
1617
+ except Exception:
1618
+ pass
1619
+
1620
+ else:
1621
+ 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.")
1622
+
1623
+ # if __name__ == "__main__":
1624
+ # main()
passenger_wsgi.py ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import os
2
+ import sys
3
+
4
+ # Ensure the application root is in the import path
5
+ APP_ROOT = os.path.dirname(os.path.abspath(__file__))
6
+ if APP_ROOT not in sys.path:
7
+ sys.path.insert(0, APP_ROOT)
8
+
9
+ # Import the starter that launches Streamlit as a subprocess
10
+ import start_streamlit # noqa: F401
11
+
12
+ # Expose a minimal WSGI callable so Passenger is satisfied
13
+ application = start_streamlit.application
quiz.py ADDED
@@ -0,0 +1,188 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+
2
+ import streamlit as st
3
+ import random
4
+ from banco import SessionLocal
5
+ from models import QuizPontuacao, QuizPergunta, QuizResposta
6
+ from datetime import datetime
7
+ from utils_auditoria import registrar_log
8
+
9
+ # ==========================
10
+ # CARREGAR PERGUNTAS DO BANCO
11
+ # ==========================
12
+ def carregar_perguntas_do_banco():
13
+ db = SessionLocal()
14
+ perguntas = []
15
+ try:
16
+ q_perguntas = db.query(QuizPergunta).filter(QuizPergunta.ativo == True).all()
17
+ for p in q_perguntas:
18
+ respostas = db.query(QuizResposta).filter(QuizResposta.pergunta_id == p.id).all()
19
+ if len(respostas) < 3:
20
+ continue # garante pelo menos 3 opções
21
+ opcoes = [r.texto for r in respostas]
22
+ correta_idx = next((i for i, r in enumerate(respostas) if r.correta), 0)
23
+ perguntas.append({
24
+ "pergunta": p.pergunta,
25
+ "opcoes": opcoes,
26
+ "resposta": correta_idx
27
+ })
28
+ finally:
29
+ db.close()
30
+ return perguntas
31
+
32
+ # ==========================
33
+ # SALVAR PONTUAÇÃO
34
+ # ==========================
35
+ def salvar_pontuacao(usuario, pontos):
36
+ db = SessionLocal()
37
+ try:
38
+ registro = QuizPontuacao(usuario=usuario, pontos=pontos, data=datetime.now())
39
+ db.add(registro)
40
+ db.commit()
41
+ db.refresh(registro) # garante que registro.id está populado
42
+ registrar_log(usuario=usuario, acao=f"Finalizou quiz com {pontos} pontos", tabela="quiz_pontuacao", registro_id=registro.id)
43
+ return registro.id
44
+ finally:
45
+ db.close()
46
+
47
+ # ==========================
48
+ # EXCLUIR PONTUAÇÃO (quando rejogar)
49
+ # ==========================
50
+ def excluir_pontuacao(registro_id):
51
+ if not registro_id:
52
+ return
53
+ db = SessionLocal()
54
+ try:
55
+ registro = db.query(QuizPontuacao).filter(QuizPontuacao.id == registro_id).first()
56
+ if registro:
57
+ db.delete(registro)
58
+ db.commit()
59
+ # (Opcional) registro de auditoria da exclusão, se desejar:
60
+ # registrar_log(usuario=registro.usuario, acao=f"Excluiu pontuação da tentativa anterior ({registro.pontos} pontos)", tabela="quiz_pontuacao", registro_id=registro_id)
61
+ finally:
62
+ db.close()
63
+
64
+ # ==========================
65
+ # VERIFICAR EXISTÊNCIA DE REGISTRO
66
+ # ==========================
67
+ def registro_existe(registro_id):
68
+ """Retorna True se o registro de pontuação ainda existe na base."""
69
+ if not registro_id:
70
+ return False
71
+ db = SessionLocal()
72
+ try:
73
+ return db.query(QuizPontuacao.id).filter(QuizPontuacao.id == registro_id).first() is not None
74
+ finally:
75
+ db.close()
76
+
77
+ # ==========================
78
+ # FUNÇÃO PRINCIPAL
79
+ # ==========================
80
+ def main():
81
+ st.title("🧠 Quiz – FPSO | WMS | IFS")
82
+
83
+ usuario = st.session_state.get("usuario")
84
+ if not usuario:
85
+ st.error("Usuário não identificado.")
86
+ return
87
+
88
+ # Inicialização de estado
89
+ if "quiz_indice" not in st.session_state:
90
+ st.session_state.quiz_indice = 0
91
+ if "quiz_pontos" not in st.session_state:
92
+ st.session_state.quiz_pontos = 0
93
+ if "quiz_salvo" not in st.session_state:
94
+ st.session_state.quiz_salvo = False
95
+ # Guarda o ID do registro salvo para permitir exclusão ao rejogar
96
+ if "quiz_registro_id" not in st.session_state:
97
+ st.session_state.quiz_registro_id = None
98
+
99
+ # Carregar perguntas
100
+ TODAS_PERGUNTAS = carregar_perguntas_do_banco()
101
+ NUM_PERGUNTAS_RODADA = 2
102
+ k = min(NUM_PERGUNTAS_RODADA, len(TODAS_PERGUNTAS))
103
+
104
+ # Caso não haja perguntas, permitir voltar ao sistema
105
+ if k == 0:
106
+ st.warning("⚠️ Nenhuma pergunta ativa disponível. Cadastre perguntas para jogar.")
107
+ if st.button("⬅️ Voltar ao sistema"):
108
+ st.session_state.quiz_verificado = True
109
+ st.session_state.quiz_pendente = False
110
+ st.rerun()
111
+ return
112
+
113
+ # Sorteio de perguntas
114
+ if "perguntas_rodada" not in st.session_state:
115
+ st.session_state.perguntas_rodada = random.sample(TODAS_PERGUNTAS, k=k)
116
+
117
+ PERGUNTAS = st.session_state.perguntas_rodada
118
+ indice = st.session_state.quiz_indice
119
+
120
+ # Final do quiz
121
+ if indice >= len(PERGUNTAS):
122
+ st.success("🎉 Quiz finalizado!")
123
+ st.metric("🏆 Pontuação", st.session_state.quiz_pontos)
124
+
125
+ # --- Novo: Se a app acha que já salvou, mas o ranking foi resetado, permita salvar de novo ---
126
+ if st.session_state.quiz_salvo and st.session_state.quiz_registro_id and not registro_existe(st.session_state.quiz_registro_id):
127
+ # O ranking foi resetado e o registro sumiu => reabrir para salvar novamente
128
+ st.info("ℹ️ O ranking foi resetado. Você pode registrar esta pontuação novamente.")
129
+ st.session_state.quiz_salvo = False
130
+ st.session_state.quiz_registro_id = None
131
+
132
+ # Salvar a pontuação apenas uma vez e guardar o ID para potencial exclusão ao rejogar
133
+ if not st.session_state.quiz_salvo:
134
+ registro_id = salvar_pontuacao(usuario, st.session_state.quiz_pontos)
135
+ st.session_state.quiz_salvo = True
136
+ st.session_state.quiz_registro_id = registro_id
137
+ st.success("✅ Pontuação registrada com sucesso.")
138
+
139
+ col1, col2 = st.columns(2)
140
+ if col1.button("🔄 Jogar novamente"):
141
+ # Ao rejogar: remover a pontuação anterior do ranking (não contar tentativa anterior)
142
+ if registro_existe(st.session_state.quiz_registro_id):
143
+ excluir_pontuacao(st.session_state.quiz_registro_id)
144
+
145
+ # Resetar estado para nova rodada
146
+ st.session_state.quiz_indice = 0
147
+ st.session_state.quiz_pontos = 0
148
+ st.session_state.quiz_salvo = False
149
+ st.session_state.quiz_registro_id = None
150
+ st.session_state.perguntas_rodada = random.sample(TODAS_PERGUNTAS, k=min(NUM_PERGUNTAS_RODADA, len(TODAS_PERGUNTAS)))
151
+ st.rerun()
152
+
153
+ if col2.button("⬅️ Voltar ao sistema"):
154
+ st.session_state.quiz_verificado = True
155
+ st.session_state.quiz_pendente = False
156
+ st.rerun()
157
+ return
158
+
159
+ # Pergunta atual
160
+ pergunta_atual = PERGUNTAS[indice]
161
+
162
+ st.progress((indice + 1) / len(PERGUNTAS))
163
+ st.caption(f"Pergunta {indice + 1} de {len(PERGUNTAS)}")
164
+
165
+ with st.form(key=f"form_pergunta_{indice}"):
166
+ st.write(f"**{pergunta_atual['pergunta']}**")
167
+ resposta = st.radio("Escolha uma opção:", pergunta_atual["opcoes"])
168
+ enviar = st.form_submit_button("Responder")
169
+
170
+ if enviar:
171
+ correta = pergunta_atual["opcoes"][pergunta_atual["resposta"]]
172
+ if resposta == correta:
173
+ st.success("✅ Resposta correta!")
174
+ st.session_state.quiz_pontos += 10
175
+ else:
176
+ st.error(f"❌ Resposta incorreta. Correta: {correta}")
177
+ st.session_state.quiz_indice += 1
178
+ st.rerun()
179
+
180
+ if __name__ == "__main__":
181
+ main()
182
+
183
+
184
+
185
+
186
+
187
+
188
+