Spaces:
Running
Running
Upload 81 files
#10
by
Roudrigus
- opened
This view is limited to 50 files because it contains too many changes.
See the raw diff here.
- .audit_report.json +219 -0
- .env +79 -0
- .gitattributes +2 -0
- .gitignore +42 -132
- .txt +2 -0
- 20260129_add_po_alt_pn_lot_batch.py +40 -0
- Inbox_Admin.py +197 -0
- Info.txt +20 -0
- Load.db +3 -0
- Load.db.bak +3 -0
- Load.py +0 -0
- Produtividade_Especialista.py +778 -0
- add_pergunta.py +129 -0
- administracao.py +883 -0
- app.py +1015 -0
- app_outlook.py +315 -0
- audit_streamlit_project.py +512 -0
- auditoria.py +100 -0
- auditoria_cleanup.py +103 -0
- auto_capture.py +319 -0
- banco.py +121 -0
- bi.py +74 -0
- cadastro_py.py +28 -0
- calendario.py +708 -0
- calendario_mensal.py +70 -0
- componentes.py +35 -0
- consulta.py +277 -0
- db_admin.py +387 -0
- db_export_import.py +359 -0
- db_monitor.py +278 -0
- db_router.py +152 -0
- db_tools.py +65 -0
- env_audit.py +321 -0
- fix_schema.py +34 -0
- form_equipamento.py +98 -0
- formulario.py +519 -0
- importar_excel.py +301 -0
- init_admin.py +33 -0
- init_db.py +38 -0
- jogos.py +384 -0
- listar_perguntas.py +22 -0
- log.py +63 -0
- login.py +175 -0
- models.py +463 -0
- module_loader.py +68 -0
- modules_map.py +263 -0
- operacao.py +1564 -0
- outlook_relatorio.py +1624 -0
- passenger_wsgi.py +13 -0
- quiz.py +188 -0
.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 |
-
#
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
#
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
.
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
| 24 |
-
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
#
|
| 34 |
-
#
|
| 35 |
-
#
|
| 36 |
-
*.
|
| 37 |
-
|
| 38 |
-
|
| 39 |
-
#
|
| 40 |
-
|
| 41 |
-
|
| 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 <>
|
| 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 <>
|
| 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 <>
|
| 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 |
+
|