IOI-RUN / administracao.py
Roudrigus's picture
Upload 82 files
0f0ef8d verified
# -*- coding: utf-8 -*-
import streamlit as st
from datetime import datetime, date
from banco import SessionLocal
from models import Equipamento
from log import registrar_log
from utils_fpso import campo_fpso
from utils_permissoes import verificar_permissao
# 🔎 Utilitários SQLAlchemy para diagnóstico e migração simples
from sqlalchemy import inspect, text
# ⬇️ Import seguro do modelo AvisoGlobal (não quebra se ainda não existir)
try:
from models import AvisoGlobal
_HAS_AVISO_GLOBAL = True
except Exception:
_HAS_AVISO_GLOBAL = False
# =====================================================
# LISTAS FIXAS
# =====================================================
MODAL_LISTA = ["", "AÉREO", "MARÍTIMO", "EXPRESSO"]
# =====================================================
# MENU INFO (DOCUMENTAÇÃO INTERNA DO SISTEMA)
# =====================================================
def menu_info():
# ✅ Apêndice de documentação: novas funcionalidades e módulos (adicional)
doc_appendix()
st.info("📌 Documentação interna do sistema. Acesso restrito a administradores.")
# =====================================================
# APÊNDICE DE DOCUMENTAÇÃO (NOVAS FUNCIONALIDADES)
# =====================================================
def doc_appendix():
"""
Adendo de documentação profissional que descreve
as novas funcionalidades, módulos e diretrizes sem
alterar o comportamento existente.
"""
st.divider()
st.subheader("📘 Atualizações e Diretrizes Profissionais")
# ✅ NOVO: documentação padronizada do Módulo Formulário dentro do apêndice
with st.expander("🧾 Módulo Formulário (padrão)", expanded=False):
st.markdown("""
**Objetivo**
Registrar, de forma padronizada, os dados operacionais de equipamentos (FPSO, Modal, OSM, MROB, métricas e administrativos), garantindo rastreabilidade e qualidade das informações.
**Funcionalidades**
- Sugestões para **FPSO** e **FPSO1** via `campo_fpso`
- Campo controlado **“Outro”** quando aplicável
- Validação de **campos obrigatórios** (ex.: FPSO, Modal, OSM, MROB)
- Registro automático de **data/hora** (`data_hora_input`)
- Persistência completa em **banco de dados** (tabela `equipamentos`)
- **Auditoria**: ações de criação/edição/exclusão registradas
**Campos Principais (Operacionais)**
- **FPSO / FPSO1**: identificação
- **Data de Coleta**
- **Especialista / Conferente / OSM**
- **Modal / Quantidade de Equipamentos / MROB**
- **Métricas**: Linhas OSM, Linhas MROB, Linhas com Erro
- **Erros**: Storekeeper, Operação WH, Especialista WH, Outros
- **Inclusão / Exclusão** (D1, D2, D3)
**Dados Administrativos**
- **PO**, **Part Number**, **Material**, **Nota Fiscal**
- **Solicitante / Requisitante**
- **Impacto / Dimensão**
- **Motivo** (Inclusão/Exclusão)
- **Observações** (campo livre)
**Validações**
- Checagem de obrigatoriedade em campos críticos
- Tratamento de valores ausentes (fallback seguro)
- Índices/sugestões pré-carregados (FPSO/Modal/OSM)
**Fluxo de Dados**
1. Usuário preenche o formulário com apoio de listas/sugestões
2. Sistema valida campos e persiste em `equipamentos`
3. Ação administrativa é registrada em **auditoria** (`log_acesso`)
4. Registros editáveis posteriormente via **Administração de Registros**
**Perfis / Permissões**
- Acesso controlado por **perfil** (admin / usuario / consulta) via `verificar_permissao`
**Impacto**
- **Padronização** dos cadastros
- **Redução de erros** operacionais
- **Rastreabilidade** completa (auditoria + carimbo de data/hora)
""")
with st.expander("📚 Estrutura de Módulos e Grupos (modules_map.py)", expanded=False):
st.markdown("""
- **Grupos suportados**: Operação Load, Backload, Operação, Terceiros, BI.
- Cada módulo deve ter: `key`, `label`, `descricao`, `perfis`, `grupo`.
- O **menu lateral** exibe: `Pesquisar módulo` → `Selecione a operação (grupo)` → `Selecione o módulo`.
- Grupos **sem módulos** (ou sem permissão) exibem: _“Em desenvolvimento”_.
- **Boas práticas**: labels padronizados, `key` único (sem acentos e espaços), controle de acesso via `perfis`.
""")
with st.expander("🧭 Navegação e UI (menu lateral)", expanded=False):
st.markdown("""
- **Pesquisa**: filtra módulos pelo `label`.
- **Selectbox de Operação**: lista grupos disponíveis.
- **Selectbox de Módulo**: exibe módulos filtrados por grupo e permissões.
- **Rodapé da sidebar**: apresenta **e-mail do usuário logado** (badge alinhado) e bloco de **versão + desenvolvedor**.
- **Layout**: `st.set_page_config(layout="wide")` habilitado, área de conteúdo responsiva.
""")
with st.expander("📧 E-mail do Usuário Logado (login + sidebar)", expanded=False):
st.markdown("""
- `login.py` grava na sessão: `st.session_state.email` e `st.session_state.nome` (se disponíveis).
- Rodapé da sidebar exibe o e-mail em **formato badge** com ícone e alinhamento (`inline-flex`).
- Caso o e-mail não apareça: verifique se o usuário possui e-mail cadastrado e/ou revalide o login.
""")
with st.expander("🧾 Auditoria com E-mail", expanded=False):
st.markdown("""
- O módulo de auditoria realiza **JOIN** com `Usuario` e agora inclui **E-mail** na consulta.
- Exportação para Excel também leva a coluna **E-mail**.
- Observação: `JOIN` padrão é interno; para logs órfãos, use `outerjoin` (se necessário).
""")
with st.expander("🛠️ Banco de Dados e Ferramentas (db_tools)", expanded=False):
st.markdown("""
- Em **SQLite** e **PostgreSQL**, as alterações (ex.: adicionar `nome` e `email` em `usuarios`) podem ser aplicadas via módulo **`db_tools`** com `ALTER TABLE` e criação de índice único (`email`).
- **Atenção**: `Base.metadata.create_all()` **não migra** tabelas existentes; para mudanças de esquema use `ALTER TABLE`, **Alembic** (recomendado) ou recrie o banco (backup antes).
- **Verificação de colunas**: `PRAGMA table_info(usuarios)` (SQLite) ou `information_schema.columns` (Postgres/MySQL).
""")
with st.expander("🎮 Jogos / Treinamento (módulo jogos)", expanded=False):
st.markdown("""
- **Jogo da Forca (Treinamento)**: perguntas por categoria, avanço de nível, contagem de tentativas.
- **Caça ao Tesouro (Níveis)**: pistas Sim/Não com feedback visual e avanço até o limite de perguntas.
- **Dado (Curiosidades)**: número de lados configurável, curiosidades de FPSO/Estoque/Óleo e Gás.
- **Pontuação e balões**: opção de efeitos visuais e pontuação acumulada.
""")
with st.expander("🧠 Quiz e Ranking", expanded=False):
st.markdown("""
- **Quiz**: perguntas dinâmicas via banco; fluxo ajustável (sem limitadores) e com opção de **Voltar ao sistema**.
- **Ranking**: consolida pontuação por rodada/período e oferece exportação.
""")
with st.expander("🎨 Diretrizes de Layout e Acessibilidade", expanded=False):
st.markdown("""
- **Responsividade**: usar `use_container_width=True` em tabelas/gráficos.
- **Colunas fluidas**: `st.columns()` para KPIs (ajuste automático em telas menores).
- **Expansores**: `st.expander()` para reduzir poluição visual.
- **Temas**: arquivo `.streamlit/config.toml` pode definir `primaryColor`, `secondaryBackgroundColor`, etc.
""")
with st.expander("🔐 Segurança e Boas Práticas", expanded=False):
st.markdown("""
- **Senhas**: sempre criptografadas (ex.: `utils_seguranca`), nunca armazenar em texto claro.
- **Perfis**: `verificar_permissao(mod_id)` controla acesso; mantenha perfis atualizados.
- **Auditoria**: registrar ações administrativas via `registrar_log(...)`.
""")
with st.expander("📦 Versionamento e Suporte", expanded=False):
st.markdown("""
- **Versão atual**: exibida no rodapé da sidebar.
- **Desenvolvedor**: contato visível na sidebar | Rodrigo Silva.
- **Próximos passos**: documentação dos novos grupos/módulos, criação de migrations com Alembic, e manuais por equipe (Operação, Backload, Terceiros, BI).
""")
# =====================================================
# 🔔 Aviso Global — helpers
# =====================================================
def _get_db_session_admin():
"""
Sessão ciente do ambiente atual (via db_router, quando disponível).
Fallback para SessionLocal().
"""
try:
from db_router import get_session_for_current_db # ajuste o nome se necessário
return get_session_for_current_db()
except Exception:
return SessionLocal()
def _sanitize_largura(largura_raw: str) -> str:
val = (largura_raw or "").strip()
if not val:
return "100%"
if val.endswith("%") or val.endswith("px"):
return val
if val.isdigit():
return f"{val}px"
return "100%"
def _obter_aviso_ativo_admin():
if not _HAS_AVISO_GLOBAL:
return None
db = _get_db_session_admin()
try:
return (
db.query(AvisoGlobal)
.filter(AvisoGlobal.ativo == True)
.order_by(AvisoGlobal.updated_at.desc(), AvisoGlobal.created_at.desc())
.first()
)
except Exception:
return None
finally:
try:
db.close()
except Exception:
pass
# 🔧 Diagnóstico e correção de schema (colunas) da tabela aviso_global
def _verificar_schema_aviso_global(show_ui: bool = True) -> bool:
"""
Retorna True se o schema está OK (inclui font_size).
Se show_ui=True, exibe UI com botão para criar coluna ausente.
"""
if not _HAS_AVISO_GLOBAL:
if show_ui:
st.error("Modelo AvisoGlobal não encontrado.")
return False
db = _get_db_session_admin()
try:
insp = inspect(db.bind)
cols = [c["name"] for c in insp.get_columns("aviso_global")]
falta_font = "font_size" not in cols
if show_ui:
with st.expander("🧪 Diagnóstico do schema (aviso_global)", expanded=False):
st.caption("Colunas atuais: " + (", ".join(cols) if cols else "—"))
if falta_font:
st.warning("A coluna **font_size** não existe neste banco/ambiente.")
col_btn1, col_btn2 = st.columns([1, 3])
if col_btn1.button("⚙️ Criar coluna font_size (DEFAULT 14)"):
try:
dialect = db.bind.dialect.name
if dialect == "sqlite":
sql = "ALTER TABLE aviso_global ADD COLUMN font_size INTEGER DEFAULT 14"
elif dialect == "postgresql":
sql = "ALTER TABLE aviso_global ADD COLUMN font_size integer DEFAULT 14"
elif dialect in ("mysql", "mariadb"):
sql = "ALTER TABLE aviso_global ADD COLUMN font_size INT DEFAULT 14"
else:
st.error(f"Dialeto não suportado para criação automática: {dialect}")
return False
db.execute(text(sql))
db.commit()
st.success("Coluna 'font_size' criada com sucesso. Recarregando...")
st.rerun()
except Exception as e:
db.rollback()
st.error(f"Erro ao criar coluna: {e}")
else:
st.success("Schema OK ✔ (coluna 'font_size' presente).")
return not falta_font
except Exception as e:
if show_ui:
st.error(f"Falha ao inspecionar o schema: {e}")
return False
finally:
try:
db.close()
except Exception:
pass
def _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size) -> bool:
if not _HAS_AVISO_GLOBAL:
return False
db = _get_db_session_admin()
try:
# desativa os ativos
db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True).update({AvisoGlobal.ativo: False})
novo = AvisoGlobal(
mensagem=(mensagem or "").strip(),
bg_color=(bg_color or "#FFF3CD").strip(),
text_color=(text_color or "#664D03").strip(),
largura=_sanitize_largura(largura),
efeito=efeito if efeito in ("marquee", "fixo") else "marquee",
velocidade=max(5, min(int(velocidade or 20), 120)),
ativo=True,
updated_at=datetime.now(),
)
# salva font_size quando o atributo/coluna existir (fallback seguro)
try:
setattr(novo, "font_size", max(10, min(int(font_size or 14), 48)))
except Exception:
pass
db.add(novo)
db.commit()
db.expire_all()
return True
except Exception as e:
db.rollback()
# Diagnóstico visível para o admin
st.error(f"Falha ao publicar o aviso: {e}")
try:
insp = inspect(db.bind)
cols = [c["name"] for c in insp.get_columns("aviso_global")]
st.caption("Colunas em aviso_global: " + ", ".join(cols))
except Exception:
pass
return False
finally:
try:
db.close()
except Exception:
pass
def _desativar_aviso_admin() -> bool:
if not _HAS_AVISO_GLOBAL:
return False
db = _get_db_session_admin()
try:
db.query(AvisoGlobal).filter(AvisoGlobal.ativo == True)\
.update({AvisoGlobal.ativo: False, AvisoGlobal.updated_at: datetime.now()})
db.commit()
db.expire_all()
return True
except Exception:
db.rollback()
return False
finally:
try:
db.close()
except Exception:
pass
# ===============================
# 🔎 Pré-visualização do Aviso Global (somente render local)
# ===============================
def _render_preview_aviso_topbar(mensagem: str, bg_color: str, text_color: str, largura: str, efeito: str, velocidade: int, font_size: int):
largura = _sanitize_largura(largura)
bg = (bg_color or "#FFF3CD").strip()
fg = (text_color or "#664D03").strip()
efeito = (efeito or "marquee").lower()
try:
velocidade = int(velocidade or 20)
except Exception:
velocidade = 20
try:
font_size = max(10, min(int(font_size or 14), 48))
except Exception:
font_size = 14
st.markdown(
f"""
<style>
.ag-topbar-wrap-preview {{
position: relative; /* preview não fixa no topo global */
width: {largura};
margin: 8px auto 10px auto;
z-index: 10;
background: {bg}; color: {fg};
border: 1px solid rgba(0,0,0,.08);
box-shadow: 0 2px 6px rgba(0,0,0,.06);
border-radius: 10px;
}}
.ag-topbar-inner-preview {{
display: flex; align-items: center;
min-height: 44px; padding: 8px 14px; overflow: hidden;
font-weight: 700; font-size: {font_size}px; letter-spacing: .2px;
white-space: nowrap;
}}
.ag-topbar-marquee-preview > span {{
display: inline-block; padding-left: 100%;
animation: ag-marquee-preview {velocidade}s linear infinite;
}}
@keyframes ag-marquee-preview {{
0% {{ transform: translateX(0); }}
100% {{ transform: translateX(-100%); }}
}}
</style>
<div class="ag-topbar-wrap-preview">
<div class="ag-topbar-inner-preview {'ag-topbar-marquee-preview' if efeito=='marquee' else ''}">
<span>{mensagem}</span>
</div>
</div>
""",
unsafe_allow_html=True
)
# =====================================================
# 🔔 Menu: Aviso Global (Topo)
# =====================================================
def menu_aviso_global():
st.subheader("📣 Aviso Global (Topo)")
st.caption("Envie um aviso global exibido no topo para todos os usuários.")
perfil = (st.session_state.get("perfil") or "usuario").strip().lower()
if perfil != "admin":
st.warning("Apenas administradores podem publicar avisos globais.")
return
if not _HAS_AVISO_GLOBAL:
st.error(
"O modelo `AvisoGlobal` não foi encontrado em `models.py`."
)
with st.expander("📄 Modelo necessário (copie para models.py)"):
st.code(
"""from banco import Base
from sqlalchemy import Column, Integer, String, Boolean, DateTime, Text
from sqlalchemy.sql import func
class AvisoGlobal(Base):
__tablename__ = "aviso_global"
id = Column(Integer, primary_key=True, index=True)
mensagem = Column(Text, nullable=False)
bg_color = Column(String(32), default="#FFF3CD")
text_color = Column(String(32), default="#664D03")
largura = Column(String(16), default="100%")
efeito = Column(String(16), default="marquee")
velocidade = Column(Integer, default=20)
font_size = Column(Integer, default=14) # tamanho da fonte (px)
ativo = Column(Boolean, default=True, index=True)
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), server_default=func.now(), onupdate=func.now())""",
language="python",
)
return
# 🔎 Diagnóstico/migração simples do schema (font_size)
_verificar_schema_aviso_global(show_ui=True)
aviso_atual = _obter_aviso_ativo_admin()
msg_default = aviso_atual.mensagem if aviso_atual else ""
bg_default = aviso_atual.bg_color if aviso_atual else "#FFF3CD"
fg_default = aviso_atual.text_color if aviso_atual else "#664D03"
w_default = aviso_atual.largura if aviso_atual else "100%"
ef_default = (aviso_atual.efeito if aviso_atual else "marquee")
vel_default = int(aviso_atual.velocidade if aviso_atual else 20)
fs_default = int(getattr(aviso_atual, "font_size", 14)) if aviso_atual else 14 # ⬅️ NOVO
mensagem = st.text_input("Mensagem do aviso:", value=msg_default, placeholder="Ex.: Manutenção hoje às 18h...")
colc1, colc2 = st.columns(2)
bg_color = colc1.color_picker("Cor de fundo", value=bg_default)
text_color = colc2.color_picker("Cor do texto", value=fg_default)
colw1, colw2 = st.columns([2,1])
largura = colw1.text_input("Largura (ex.: 100% ou 1200px)", value=w_default)
efeito = colw2.selectbox("Efeito", ["marquee", "fixo"], index=(0 if ef_default=="marquee" else 1))
colv1, colv2 = st.columns(2)
velocidade = colv1.slider("Velocidade (segundos por ciclo)", min_value=5, max_value=120, value=vel_default, step=1, help="Usado apenas no modo 'marquee'.")
font_size = colv2.slider("Tamanho da fonte (px)", min_value=10, max_value=48, value=fs_default, step=1) # ⬅️ NOVO
# --- Pré-visualização ao vivo (sem salvar) ---
st.markdown("**Pré-visualização:**")
if (mensagem or "").strip():
_render_preview_aviso_topbar(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size)
else:
st.info("Digite a mensagem para ver a pré-visualização aqui.")
colb1, colb2, colb3 = st.columns(3)
publicar = colb1.button("📢 Publicar/Atualizar aviso")
desativar = colb2.button("🛑 Desativar aviso atual")
atualizar_preview = colb3.button("🔄 Atualizar prévia")
# Botão opcional de refresh da prévia (não salva nada; rerenderiza a página).
if atualizar_preview:
st.rerun()
if publicar:
if not (mensagem or "").strip():
st.warning("Digite a mensagem do aviso.")
else:
ok = _publicar_aviso_admin(mensagem, bg_color, text_color, largura, efeito, velocidade, font_size)
if ok:
try:
registrar_log(
usuario=st.session_state.get("usuario"),
acao="PUBLICAR_AVISO_GLOBAL",
tabela="aviso_global",
registro_id=None
)
except Exception:
pass
st.success("Aviso publicado/atualizado!")
st.rerun()
else:
st.error("Não foi possível publicar o aviso. Verifique o banco/logs.")
if desativar:
ok = _desativar_aviso_admin()
if ok:
try:
registrar_log(
usuario=st.session_state.get("usuario"),
acao="DESATIVAR_AVISO_GLOBAL",
tabela="aviso_global",
registro_id=None
)
except Exception:
pass
st.info("Aviso desativado.")
st.rerun()
else:
st.error("Não foi possível desativar o aviso.")
# =====================================================
# ADMINISTRAÇÃO (variação com abas/tabs)
# =====================================================
def main():
# ✅ Detecta se usuário é admin; abas administrativas aparecem apenas para admin.
is_admin = verificar_permissao("administracao")
# Título conforme perfil
if is_admin:
st.title("🔒 Administração")
# Admin vê todas as abas
tab_editar, tab_aviso, tab_info = st.tabs([
"✏️ Editar / Excluir Registros",
"📣 Aviso Global (Topo)",
"📘 Info do Sistema"
])
else:
st.title("✏️ Edição de Registros")
# Não-admin vê apenas a aba de edição
(tab_editar,) = st.tabs(["✏️ Editar Registros"])
# =====================================================
# BLOCO: INFO DO SISTEMA (apenas admin)
# =====================================================
if is_admin:
with tab_info:
menu_info()
# =====================================================
# BLOCO: AVISO GLOBAL (apenas admin)
# =====================================================
with tab_aviso:
menu_aviso_global()
# =====================================================
# BLOCO: EDIÇÃO / EXCLUSÃO (excluir só admin)
# =====================================================
with tab_editar:
# =====================================================
# FUNÇÃO UTILITÁRIA
# =====================================================
def safe_index(lista, valor):
"""Evita erro quando o valor salvo no banco não existe na lista"""
try:
return lista.index(valor)
except ValueError:
return 0
db = SessionLocal()
try:
# =====================================================
# 🔎 FILTROS OPCIONAIS COM SUGESTÕES DO BANCO
# (disponível para todos os perfis)
# =====================================================
st.subheader("🔎 Filtro de Busca (opcional)")
# IMPORTANTE: usar .distinct() sobre a coluna, como já estava
fpsos = [""] + sorted({r.fpso for r in db.query(Equipamento.fpso).distinct() if r.fpso})
modais = [""] + sorted({r.modal for r in db.query(Equipamento.modal).distinct() if r.modal})
osms = [""] + sorted({r.osm for r in db.query(Equipamento.osm).distinct() if r.osm})
# 🟩 NOVO: lista de Nota Fiscal para multiselect assistido
notas_dist = [""] + sorted({str(r.nota_fiscal) for r in db.query(Equipamento.nota_fiscal).distinct() if r.nota_fiscal})
col1, col2, col3, col4 = st.columns(4)
with col1:
filtro_fpso = st.selectbox("FPSO", fpsos)
with col2:
filtro_modal = st.selectbox("Modal", modais)
with col3:
filtro_osm = st.selectbox("OSM", osms)
with col4:
filtro_data = st.date_input("Data Coleta", value=None)
# 🟩 NOVO: filtros de Nota Fiscal + opção de ver só duplicadas
st.markdown("**🧾 Filtro por Nota Fiscal**")
nf_col1, nf_col2, nf_col3 = st.columns([2, 2, 1.2])
with nf_col1:
filtro_nf_text = st.text_input(
"Digite uma ou mais NFs (separadas por vírgula)",
value=""
)
with nf_col2:
filtro_nf_multi = st.multiselect(
"Ou selecione",
options=[x for x in notas_dist if x != ""]
)
with nf_col3:
mostrar_apenas_nf_duplicadas = st.checkbox(
"Somente duplicadas",
value=False
)
# =====================================================
# QUERY BASE (COMPORTAMENTO ORIGINAL) + NOVO FILTRO NF
# =====================================================
query = db.query(Equipamento)
if filtro_fpso:
query = query.filter(Equipamento.fpso == filtro_fpso)
if filtro_modal:
query = query.filter(Equipamento.modal == filtro_modal)
if filtro_osm:
query = query.filter(Equipamento.osm == filtro_osm)
if filtro_data:
query = query.filter(Equipamento.data_coleta == filtro_data)
# 🟩 NOVO: aplica filtro de Nota Fiscal (tratando como string)
notas_escolhidas = set()
if filtro_nf_text.strip():
partes = [p.strip() for p in filtro_nf_text.split(",") if p.strip()]
notas_escolhidas.update(partes)
if filtro_nf_multi:
notas_escolhidas.update([str(x).strip() for x in filtro_nf_multi if str(x).strip()])
if notas_escolhidas:
# Como a coluna é do tipo texto no modelo, filtramos por igualdade textual.
# Para outros dialetos/formatos numéricos, garantir cast adequado.
query = query.filter(Equipamento.nota_fiscal.in_(list(notas_escolhidas)))
registros = query.order_by(Equipamento.id.desc()).all()
if not registros:
st.info("Nenhum registro encontrado.")
return
# =====================================================
# 🧭 SINALIZAÇÃO DE NF DUPLICADA (no conjunto filtrado)
# =====================================================
# Monta DF auxiliar só com campos relevantes para contagem de NF
import pandas as pd
df_aux = pd.DataFrame([{
"ID": r.id,
"Nota Fiscal": ("" if r.nota_fiscal is None else str(r.nota_fiscal).strip())
} for r in registros])
# Contagem de ocorrências por NF (string, ignorando vazias)
if not df_aux.empty:
contagem = df_aux.loc[df_aux["Nota Fiscal"] != "", "Nota Fiscal"].value_counts()
notas_duplicadas = contagem[contagem > 1]
else:
notas_duplicadas = pd.Series(dtype=int)
# Aviso e expander com a lista das duplicadas
if len(notas_duplicadas.index) > 0:
total_ocorrencias = int(notas_duplicadas.sum())
st.warning(
f"⚠️ Foram encontradas **{total_ocorrencias}** ocorrências em **{len(notas_duplicadas)}** "
f"números de Nota Fiscal duplicados no resultado filtrado."
)
with st.expander("Ver lista de notas duplicadas"):
st.dataframe(
notas_duplicadas.rename("Ocorrências").reset_index().rename(columns={"index": "Nota Fiscal"}),
use_container_width=True
)
# Se marcado: mantém na lista apenas as duplicadas
if mostrar_apenas_nf_duplicadas:
set_dup = set(notas_duplicadas.index.tolist())
registros = [r for r in registros if (r.nota_fiscal is not None and str(r.nota_fiscal).strip() in set_dup)]
if not registros:
st.info("Nenhum registro duplicado após aplicar o filtro de 'Somente duplicadas'.")
return
else:
if mostrar_apenas_nf_duplicadas:
st.info("Não há notas duplicadas no conjunto filtrado.")
return
# =====================================================
# SELECTBOX DE ESCOLHA E FORMULÁRIO
# =====================================================
mapa = {
f"ID {r.id} | FPSO {r.fpso} | {r.modal} | {r.osm} | {r.data_coleta} | NF: {r.nota_fiscal or '—'}": r.id
for r in registros
}
escolha = st.selectbox("Selecione o registro", list(mapa.keys()))
registro = db.get(Equipamento, mapa[escolha])
st.divider()
st.subheader("✏️ Editar Registro")
# =====================================================
# FORMULÁRIO COMPLETO (MESMO DO MÓDULO FORMULÁRIO)
# =====================================================
with st.form("form_edicao"):
# ================== DADOS OPERACIONAIS ==================
st.subheader("📦 Dados Operacionais")
col1, col2, col3 = st.columns(3)
with col1:
fpso1 = campo_fpso("FPSO1", registro.fpso1)
fpso = campo_fpso("FPSO", registro.fpso)
data_coleta = st.date_input("Data de Coleta", registro.data_coleta)
especialista = st.text_input("Especialista", registro.especialista or "")
conferente = st.text_input("Conferente", registro.conferente or "")
osm = st.text_input("OSM", registro.osm or "")
with col2:
modal = st.selectbox(
"Modal",
MODAL_LISTA,
index=safe_index(MODAL_LISTA, registro.modal)
)
quant_equip = st.number_input(
"Quantidade de Equipamentos",
min_value=0,
value=registro.quant_equip or 0
)
mrob = st.text_input("MROB", registro.mrob or "")
with col3:
linhas_osm = st.number_input("Total de Linhas OSM", value=registro.linhas_osm or 0)
linhas_mrob = st.number_input("Total de Linhas MROB", value=registro.linhas_mrob or 0)
linhas_erros = st.number_input("Total de Linhas com Erro", value=registro.linhas_erros or 0)
st.divider()
# ================== ANÁLISE DE ERROS ==================
st.subheader("⚠️ Análise de Erros")
op_sim_nao = ["", "Sim", "Não"]
col_e1, col_e2, col_e3, col_e4 = st.columns(4)
with col_e1:
erro_storekeeper = st.selectbox(
"Storekeeper", op_sim_nao,
index=safe_index(op_sim_nao, registro.erro_storekeeper)
)
with col_e2:
erro_operacao = st.selectbox(
"Operação WH", op_sim_nao,
index=safe_index(op_sim_nao, registro.erro_operacao)
)
with col_e3:
erro_especialista = st.selectbox(
"Especialista WH", op_sim_nao,
index=safe_index(op_sim_nao, registro.erro_especialista)
)
with col_e4:
erro_outros = st.selectbox(
"Outros", op_sim_nao,
index=safe_index(op_sim_nao, registro.erro_outros)
)
op_inc_exc = ["", "INCLUSÃO", "EXCLUSÃO"]
inclusao_exclusao = st.selectbox(
"Inclusão / Exclusão",
op_inc_exc,
index=safe_index(op_inc_exc, registro.inclusao_exclusao)
)
st.divider()
# ================== DADOS ADMINISTRATIVOS ==================
st.subheader("🧾 Dados Administrativos")
col_a1, col_a2, col_a3 = st.columns(3)
with col_a1:
po = st.text_input("PO", registro.po or "")
part_number = st.text_input("Part Number", registro.part_number or "")
with col_a2:
material = st.text_input("Material", registro.material or "")
nota_fiscal = st.text_input("Nota Fiscal", registro.nota_fiscal or "")
with col_a3:
solicitante = st.text_input("Solicitante", registro.solicitante or "")
requisitante = st.text_input("Requisitante", registro.requisitante or "")
impacto = st.text_input("Impacto", registro.impacto or "")
dimensao = st.text_input("Dimensão", registro.dimensao or "")
# ✅ AJUSTE: corrigido para 'motivo'
motivo = st.text_input("Motivo da Inclusão / Exclusão", registro.motivo or "")
observacoes = st.text_area(
"Observações",
registro.observacoes or "",
height=120
)
op_dia = ["", "D1", "D2", "D3"]
dia_inclusao = st.selectbox(
"Dia de Inclusão (D)",
op_dia,
index=safe_index(op_dia, registro.dia_inclusao)
)
# ================== AÇÃO ==================
# 🔐 Apenas admin pode excluir
opcoes_acao = ["Salvar Alterações"] + (["Excluir Registro"] if is_admin else [])
acao = st.radio(
"Ação",
opcoes_acao,
horizontal=True
)
submit = st.form_submit_button("Confirmar")
# =====================================================
# AÇÕES
# =====================================================
if submit:
if acao == "Salvar Alterações":
# Atualiza todos os campos dinamicamente (exceto id)
for campo in registro.__table__.columns.keys():
if campo != "id":
setattr(registro, campo, locals().get(campo, getattr(registro, campo)))
registro.data_hora_input = datetime.now()
db.commit()
try:
registrar_log(
usuario=st.session_state.get("usuario"),
acao="EDITAR",
tabela="equipamentos",
registro_id=registro.id
)
except Exception:
pass
st.success("✅ Registro atualizado com sucesso!")
st.rerun()
elif acao == "Excluir Registro" and is_admin:
db.delete(registro)
db.commit()
try:
registrar_log(
usuario=st.session_state.get("usuario"),
acao="EXCLUIR",
tabela="equipamentos",
registro_id=registro.id
)
except Exception:
pass
st.success("🗑️ Registro excluído com sucesso!")
st.rerun()
finally:
try:
db.close()
except Exception:
pass