Roudrigus commited on
Commit
e199519
·
verified ·
1 Parent(s): 296b115

Update importar_excel.py

Browse files
Files changed (1) hide show
  1. importar_excel.py +433 -301
importar_excel.py CHANGED
@@ -1,301 +1,433 @@
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
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ importar_excel.py
4
+ Importa planilhas Excel para a tabela Equipamento, com:
5
+ - Geração de modelo Excel
6
+ - Upload e pré-visualização
7
+ - Verificação/edição de duplicidades
8
+ - Conversões seguras de tipos (datas/números/strings)
9
+ - Gravação transacional + auditoria resiliente
10
+ Compatível com Linux (Spaces) e local/Windows.
11
+ """
12
+
13
+ import streamlit as st
14
+ import pandas as pd
15
+ from io import BytesIO
16
+ from datetime import datetime, date
17
+
18
+ from banco import SessionLocal
19
+ from models import Equipamento
20
+ from utils_auditoria import registrar_log
21
+
22
+ # =========================================
23
+ # Configuração – nomes de colunas esperadas
24
+ # =========================================
25
+ COLUNAS_ESPERADAS = [
26
+ "fpso1", "fpso", "data_coleta", "especialista", "conferente", "osm", "modal",
27
+ "quant_equip", "mrob", "linhas_osm", "linhas_mrob", "linhas_erros",
28
+ "erro_storekeeper", "erro_operacao", "erro_especialista", "erro_outros",
29
+ "inclusao_exclusao", "po", "part_number", "material", "solicitante", "motivo",
30
+ "requisitante", "nota_fiscal", "impacto", "dimensao", "observacoes", "dia_inclusao",
31
+ ]
32
+
33
+ # Colunas que tratamos como números inteiros na gravação
34
+ COLS_INTEIRAS = {
35
+ "quant_equip", "linhas_osm", "linhas_mrob", "linhas_erros"
36
+ }
37
+
38
+ # =====================================================
39
+ # FUNÇÕES AUXILIARES
40
+ # =====================================================
41
+ def _normalize_cols_case_insensitive(df: pd.DataFrame) -> pd.DataFrame:
42
+ """
43
+ Mapeia colunas do arquivo (case/espaços) para os nomes esperados em COLUNAS_ESPERADAS.
44
+ Ex.: " FPSo " -> "fpso"; "DATA_COLETA" -> "data_coleta".
45
+ Colunas não reconhecidas permanecem como estão.
46
+ """
47
+ if df is None or df.empty:
48
+ return df
49
+
50
+ # prioriza mapeamento por lower(strip)
51
+ existing = list(df.columns)
52
+ lower_map = {c.strip().lower(): c for c in existing}
53
+ new_names = {}
54
+ for wanted in COLUNAS_ESPERADAS:
55
+ key = wanted.lower()
56
+ if key in lower_map:
57
+ new_names[lower_map[key]] = wanted # renomeia p/ o nome "oficial" esperado
58
+
59
+ # aplica renomeação
60
+ try:
61
+ df = df.rename(columns=new_names)
62
+ except Exception:
63
+ pass
64
+ return df
65
+
66
+
67
+ def to_date(value):
68
+ """
69
+ Converte para date:
70
+ - pandas.Timestamp/datetime/date -> date
71
+ - string em formatos 'YYYY-MM-DD' ou 'DD/MM/YYYY'
72
+ - retorna None se não puder converter
73
+ Necessário para compatibilidade com SQLite e outros.
74
+ """
75
+ if value is None or (isinstance(value, float) and pd.isna(value)) or pd.isna(value):
76
+ return None
77
+
78
+ if isinstance(value, pd.Timestamp):
79
+ return value.date()
80
+ if isinstance(value, datetime):
81
+ return value.date()
82
+ if isinstance(value, date):
83
+ return value
84
+
85
+ if isinstance(value, str):
86
+ s = value.strip()
87
+ if not s:
88
+ return None
89
+ # tenta ISO
90
+ try:
91
+ return datetime.strptime(s, "%Y-%m-%d").date()
92
+ except Exception:
93
+ pass
94
+ # tenta BR
95
+ try:
96
+ return datetime.strptime(s, "%d/%m/%Y").date()
97
+ except Exception:
98
+ pass
99
+ return None
100
+
101
+
102
+ def safe_value(value):
103
+ """
104
+ Retorna 0 se o valor for vazio/NaN; senão o próprio valor.
105
+ Usado para campos "livres" que não podem ir nulos.
106
+ """
107
+ if value is None or (isinstance(value, float) and pd.isna(value)) or pd.isna(value):
108
+ return 0
109
+ return value
110
+
111
+
112
+ def safe_int(value) -> int:
113
+ """
114
+ Converte para int com fallback (None/NaN/erro -> 0).
115
+ """
116
+ try:
117
+ if value is None or pd.isna(value):
118
+ return 0
119
+ # strings vazias
120
+ if isinstance(value, str) and value.strip() == "":
121
+ return 0
122
+ return int(float(value))
123
+ except Exception:
124
+ return 0
125
+
126
+
127
+ def safe_str(value) -> str:
128
+ """
129
+ Converte para string segura (None/NaN -> "").
130
+ """
131
+ if value is None or (isinstance(value, float) and pd.isna(value)) or pd.isna(value):
132
+ return ""
133
+ return str(value)
134
+
135
+
136
+ def _df_template() -> pd.DataFrame:
137
+ """
138
+ Retorna um DataFrame vazio com as colunas esperadas (para gerar o modelo).
139
+ """
140
+ return pd.DataFrame(columns=COLUNAS_ESPERADAS)
141
+
142
+
143
+ def _download_modelo_excel() -> BytesIO:
144
+ """
145
+ Gera um arquivo Excel de modelo (aba MODELO) e retorna buffer.
146
+ """
147
+ df = _df_template()
148
+ buf = BytesIO()
149
+ with pd.ExcelWriter(buf, engine="openpyxl") as writer:
150
+ df.to_excel(writer, index=False, sheet_name="MODELO")
151
+ buf.seek(0)
152
+ return buf
153
+
154
+
155
+ def _validate_required_cols(df: pd.DataFrame) -> list:
156
+ """
157
+ Retorna a lista de colunas faltantes em relação a COLUNAS_ESPERADAS.
158
+ Apenas informa; a importação pode prosseguir mesmo faltando algumas
159
+ (desde que sua tabela/ETL aceite nulos).
160
+ """
161
+ missing = [c for c in COLUNAS_ESPERADAS if c not in df.columns]
162
+ return missing
163
+
164
+
165
+ # =====================================================
166
+ # MÓDULO PRINCIPAL
167
+ # =====================================================
168
+ def main():
169
+ st.title("📥 Importação de Dados via Excel")
170
+
171
+ st.markdown(
172
+ """
173
+ Este módulo permite:
174
+ - 📄 Baixar um **modelo Excel padrão**
175
+ - ✍️ Preencher os dados offline
176
+ - 🔍 Validar antes da gravação
177
+ - 💾 Importar os registros para o banco
178
+ """
179
+ )
180
+
181
+ # =====================================================
182
+ # 1️⃣ GERAR MODELO EXCEL
183
+ # =====================================================
184
+ st.subheader("📄 Baixar modelo Excel")
185
+
186
+ buffer = _download_modelo_excel()
187
+ st.download_button(
188
+ label="⬇️ Baixar modelo Excel",
189
+ data=buffer,
190
+ file_name="modelo_importacao_load.xlsx",
191
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
192
+ )
193
+
194
+ st.divider()
195
+
196
+ # =====================================================
197
+ # 2️⃣ UPLOAD DO ARQUIVO
198
+ # =====================================================
199
+ st.subheader("📤 Importar arquivo preenchido")
200
+
201
+ arquivo = st.file_uploader(
202
+ "Selecione o arquivo Excel (.xlsx)",
203
+ type=["xlsx"]
204
+ )
205
+
206
+ if not arquivo:
207
+ st.info("📌 Faça o upload de um arquivo para continuar.")
208
+ return
209
+
210
+ # Leitura com engine explícito para maior compatibilidade
211
+ try:
212
+ df = pd.read_excel(arquivo, engine="openpyxl")
213
+ except Exception as e:
214
+ st.error(f" Erro ao ler o arquivo: {e}")
215
+ return
216
+
217
+ if df is None or df.empty:
218
+ st.error("❌ O arquivo não possui dados (planilha vazia).")
219
+ return
220
+
221
+ # Normaliza colunas (case/espaços) para nomes esperados
222
+ df = _normalize_cols_case_insensitive(df)
223
+
224
+ # Garante um identificador estável para cada linha durante as edições
225
+ if "_row_id" not in df.columns:
226
+ df["_row_id"] = range(len(df))
227
+
228
+ # Salva o DF bruto na sessão para persistência entre reruns
229
+ st.session_state["df_raw"] = df.copy()
230
+
231
+ # Diagnóstico de colunas
232
+ faltantes = _validate_required_cols(df)
233
+ if faltantes:
234
+ st.warning(
235
+ "⚠️ Algumas colunas esperadas **não** foram encontradas no arquivo ("
236
+ + ", ".join(faltantes) + "). "
237
+ "Se esses campos forem obrigatórios em seu banco, a importação pode falhar."
238
+ )
239
+
240
+ st.success("✅ Arquivo carregado com sucesso!")
241
+
242
+ # =====================================================
243
+ # 3️⃣ PRÉVIA DOS DADOS
244
+ # =====================================================
245
+ st.subheader("🔍 Prévia dos dados (arquivo lido)")
246
+ st.dataframe(df, use_container_width=True)
247
+
248
+ # =====================================================
249
+ # 4️⃣ VERIFICAÇÃO DE DUPLICIDADE (com seleção de linhas a excluir)
250
+ # =====================================================
251
+ st.subheader("🧪 Verificação de duplicidade")
252
+
253
+ st.caption(
254
+ "Escolha as colunas que definem a duplicidade. "
255
+ "Em seguida, marque o checkbox **_excluir** nas linhas que **não** deseja importar."
256
+ )
257
+
258
+ # Sugerimos um conjunto padrão de chaves, mas só usamos as que existem no arquivo
259
+ sugestao_chaves = [c for c in ["fpso", "osm", "po", "part_number", "nota_fiscal", "data_coleta"] if c in df.columns]
260
+
261
+ chaves = st.multiselect(
262
+ "📌 Colunas para verificação de duplicidade:",
263
+ options=list(df.columns),
264
+ default=sugestao_chaves if len(sugestao_chaves) > 0 else []
265
+ )
266
+
267
+ # Prepara um DF de trabalho com colunas auxiliares
268
+ work_df = df.copy()
269
+ work_df["_duplicado"] = False
270
+ work_df["_excluir"] = False # será editado pelo usuário
271
+
272
+ if len(chaves) == 0:
273
+ st.info("Selecione ao menos **uma** coluna para verificar duplicidade.")
274
+ # Mesmo sem duplicidade definida, permitimos marcar exclusões manuais:
275
+ with st.expander("🔧 (Opcional) Excluir linhas manualmente mesmo sem duplicidade"):
276
+ manual_view = work_df.set_index("_row_id")[
277
+ [c for c in work_df.columns if c not in ["_duplicado"]] + ["_excluir"]
278
+ ]
279
+ edited_manual = st.data_editor(
280
+ manual_view,
281
+ use_container_width=True,
282
+ num_rows="fixed",
283
+ column_config={
284
+ "_excluir": st.column_config.CheckboxColumn("Excluir da importação")
285
+ }
286
+ )
287
+ # Aplica exclusões manuais
288
+ work_df = work_df.set_index("_row_id")
289
+ work_df["_excluir"] = edited_manual["_excluir"].reindex(work_df.index).fillna(False).astype(bool)
290
+ work_df = work_df.reset_index()
291
+
292
+ else:
293
+ # Marca duplicadas com base nas chaves selecionadas
294
+ mask_dup_any = work_df.duplicated(subset=chaves, keep=False)
295
+ work_df["_duplicado"] = mask_dup_any
296
+
297
+ # Sugerimos exclusão automática das ocorrências não-primárias (o usuário pode alterar)
298
+ mask_dup_not_first = work_df.duplicated(subset=chaves, keep="first")
299
+ work_df.loc[mask_dup_not_first, "_excluir"] = True
300
+
301
+ if mask_dup_any.any():
302
+ st.warning("⚠️ Foram encontradas linhas duplicadas com base nas chaves selecionadas:")
303
+ # Mostrar somente as duplicadas para facilitar a decisão
304
+ cols_para_mostrar = chaves + [c for c in ["_duplicado", "_excluir"] if c not in chaves]
305
+ # Evita colunas repetidas mantendo ordem
306
+ seen = set()
307
+ cols_para_mostrar = [c for c in cols_para_mostrar if not (c in seen or seen.add(c))]
308
+
309
+ dup_view = work_df.loc[mask_dup_any].set_index("_row_id")[cols_para_mostrar]
310
+ edited_dup = st.data_editor(
311
+ dup_view,
312
+ use_container_width=True,
313
+ num_rows="fixed",
314
+ column_config={
315
+ "_excluir": st.column_config.CheckboxColumn("Excluir da importação"),
316
+ "_duplicado": st.column_config.CheckboxColumn("Duplicado", disabled=True)
317
+ }
318
+ )
319
+
320
+ # Mescla de volta as escolhas do usuário
321
+ work_df = work_df.set_index("_row_id")
322
+ if "_excluir" in edited_dup.columns:
323
+ work_df.loc[edited_dup.index, "_excluir"] = (
324
+ edited_dup["_excluir"].reindex(work_df.index).fillna(work_df["_excluir"]).astype(bool)
325
+ )
326
+ work_df = work_df.reset_index()
327
+
328
+ st.info(
329
+ f"📊 Totais — Linhas: {len(work_df)} | Duplicadas: {mask_dup_any.sum()} | "
330
+ f"Marcadas para excluir: {int(work_df['_excluir'].sum())}"
331
+ )
332
+ else:
333
+ st.success("✅ Nenhuma duplicidade encontrada com as chaves selecionadas.")
334
+
335
+ st.divider()
336
+
337
+ # =====================================================
338
+ # 4.1️⃣ Prévia final do que será importado + download
339
+ # =====================================================
340
+ df_para_importar = work_df[~work_df["_excluir"]].drop(columns=["_duplicado", "_excluir"], errors="ignore")
341
+ st.session_state["df_para_importar"] = df_para_importar.copy()
342
+
343
+ st.subheader("🧾 Prévia do que será importado")
344
+ st.caption("A prévia abaixo desconsidera as linhas marcadas como **_excluir**.")
345
+ st.dataframe(df_para_importar.drop(columns=["_row_id"], errors="ignore"), use_container_width=True)
346
+
347
+ # Download da prévia para conferência
348
+ buf_prev = BytesIO()
349
+ with pd.ExcelWriter(buf_prev, engine="openpyxl") as writer:
350
+ df_para_importar.drop(columns=["_row_id"], errors="ignore").to_excel(writer, index=False, sheet_name="A_IMPORTAR")
351
+ buf_prev.seek(0)
352
+ st.download_button(
353
+ "⬇️ Baixar prévia (Excel)",
354
+ data=buf_prev,
355
+ file_name="previa_a_importar.xlsx",
356
+ mime="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
357
+ help="Baixe a prévia do conjunto que será gravado."
358
+ )
359
+
360
+ # =====================================================
361
+ # 5️⃣ GRAVAÇÃO NO BANCO (usa o DataFrame filtrado)
362
+ # =====================================================
363
+ st.subheader("💾 Gravar dados no banco")
364
+
365
+ col1, col2 = st.columns(2)
366
+
367
+ if col1.button("💾 Salvar registros importados"):
368
+ df_import = st.session_state.get("df_para_importar", df)
369
+
370
+ if df_import.empty:
371
+ st.error("Não há registros para importar. Revise as exclusões.")
372
+ return
373
+
374
+ with SessionLocal() as db:
375
+ try:
376
+ for _, row in df_import.iterrows():
377
+ registro = Equipamento(
378
+ fpso1=safe_str(row.get("fpso1")),
379
+ fpso=safe_str(row.get("fpso")),
380
+ data_coleta=to_date(row.get("data_coleta")),
381
+ especialista=safe_str(row.get("especialista")),
382
+ conferente=safe_str(row.get("conferente")),
383
+ osm=safe_str(row.get("osm")),
384
+ modal=safe_str(row.get("modal")),
385
+ quant_equip=safe_int(row.get("quant_equip")),
386
+ mrob=safe_str(row.get("mrob")),
387
+ linhas_osm=safe_int(row.get("linhas_osm")),
388
+ linhas_mrob=safe_int(row.get("linhas_mrob")),
389
+ linhas_erros=safe_int(row.get("linhas_erros")),
390
+ erro_storekeeper=safe_str(row.get("erro_storekeeper")),
391
+ erro_operacao=safe_str(row.get("erro_operacao")),
392
+ erro_especialista=safe_str(row.get("erro_especialista")),
393
+ erro_outros=safe_str(row.get("erro_outros")),
394
+ inclusao_exclusao=safe_str(row.get("inclusao_exclusao")),
395
+ po=safe_str(row.get("po")),
396
+ part_number=safe_str(row.get("part_number")),
397
+ material=safe_str(row.get("material")),
398
+ solicitante=safe_str(row.get("solicitante")),
399
+ motivo=safe_str(row.get("motivo")),
400
+ requisitante=safe_str(row.get("requisitante")),
401
+ nota_fiscal=safe_str(row.get("nota_fiscal")),
402
+ impacto=safe_str(row.get("impacto")),
403
+ dimensao=safe_str(row.get("dimensao")),
404
+ observacoes=safe_str(row.get("observacoes")),
405
+ dia_inclusao=safe_str(row.get("dia_inclusao")),
406
+ )
407
+ db.add(registro)
408
+
409
+ db.commit()
410
+
411
+ try:
412
+ registrar_log(
413
+ usuario=st.session_state.get("usuario"),
414
+ acao=f"IMPORTAÇÃO EXCEL ({len(df_import)} registros) - com filtro de duplicidade",
415
+ tabela="equipamentos",
416
+ registro_id=None
417
+ )
418
+ except Exception:
419
+ # Não quebra o fluxo se auditoria falhar
420
+ pass
421
+
422
+ st.success(f"🎉 Importação concluída com sucesso! {len(df_import)} registros gravados.")
423
+
424
+ except Exception as e:
425
+ db.rollback()
426
+ st.error(f"❌ Erro ao gravar no banco: {e}")
427
+
428
+ if col2.button("❌ Cancelar importação"):
429
+ st.warning("Importação cancelada pelo usuário.")
430
+
431
+
432
+ if __name__ == "__main__":
433
+ main()