diff --git "a/recebimento.py" "b/recebimento.py" --- "a/recebimento.py" +++ "b/recebimento.py" @@ -1,2070 +1,2066 @@ -# -*- coding: utf-8 -*- -import io -import re -import secrets -import hashlib -import unicodedata -from datetime import date, datetime, time, timedelta -from typing import Any, Dict, List, Optional, Tuple, Callable - -import pandas as pd -import streamlit as st - -# Altair (para gráficos com linhas/metas). Fallback automático se não existir. -try: - import altair as alt - ALT_AVAILABLE = True -except Exception: - ALT_AVAILABLE = False - -from banco import SessionLocal -from models import RecebimentoRegistro - - -# ========================================================== -# Sessão de banco (compatível com seu app) -# ========================================================== -def _get_db_session(): - try: - from db_router import get_session_for_current_db - return get_session_for_current_db() - except Exception: - return SessionLocal() - - -# ========================================================== -# Utilitários de exportação (CSV/Excel) — usar em todo o app -# ========================================================== -def _df_to_excel_bytes(df: pd.DataFrame, sheet_name: str = "Dados") -> bytes: - """ - Converte um DataFrame em bytes Excel (.xlsx) usando openpyxl. - Retorna b"" se o engine não estiver disponível. - """ - bio = io.BytesIO() - try: - with pd.ExcelWriter(bio, engine="openpyxl") as xw: - df.to_excel(xw, index=False, sheet_name=sheet_name) - bio.seek(0) - return bio.getvalue() - except Exception: - # Fallback: retorna vazio para o chamador desabilitar o botão - return b"" - - -def _download_buttons(df: pd.DataFrame, filename_prefix: str): - """ - Renderiza dois botões de download (CSV e Excel) para um DataFrame. - Usa _df_to_excel_bytes e desabilita o botão de Excel se openpyxl indisponível. - """ - c1, c2 = st.columns(2) - csv_bytes = df.to_csv(index=False).encode("utf-8-sig") - c1.download_button("⬇️ Baixar CSV", data=csv_bytes, file_name=f"{filename_prefix}.csv") - - xlsx = _df_to_excel_bytes(df) - if xlsx: - c2.download_button("⬇️ Baixar Excel", data=xlsx, file_name=f"{filename_prefix}.xlsx") - else: - c2.caption("Excel indisponível (openpyxl ausente).") - - -# ========================================================== -# Conversores padronizados -# ========================================================== -def conv_bool(v): - if v is None: - return None - s = str(v).strip().upper() - if s in ("SIM", "S", "TRUE", "1"): - return True - if s in ("NÃO", "NAO", "N", "FALSE", "0"): - return False - return None # N/A ou vazio - - -def conv_date(v): - if v is None or v == "": - return None - if isinstance(v, date) and not isinstance(v, datetime): - return v - try: - return pd.to_datetime(v, dayfirst=True, errors="coerce").date() - except Exception: - return None - - -def _normalize_hms_str(s: str) -> str: - """ - Normaliza strings flexíveis para HH:MM:SS. - """ - s0 = str(s).strip() - if not s0: - return "" - - # troca separadores incomuns por ':' - s1 = re.sub(r"[hH]", ":", s0) - s1 = re.sub(r"[mM]", ":", s1) - s1 = re.sub(r"[sS]", ":", s1) - s1 = re.sub(r"[.;,\s]+", ":", s1) - - if re.fullmatch(r"\d{1,6}", s1): - n = s1 - if len(n) <= 2: # "8" -> HH - hh = int(n) - return f"{hh:02d}:00:00" - elif len(n) in (3, 4): # "815" / "0815" -> HHMM - hh = int(n[:-2]); mm = int(n[-2:]) - return f"{hh:02d}:{mm:02d}:00" - elif len(n) in (5, 6): # "81530" / "081530" -> HHMMSS - hh = int(n[:-4]); mm = int(n[-4:-2]); ss = int(n[-2:]) - return f"{hh:02d}:{mm:02d}:{ss:02d}" - - parts = [p for p in s1.split(":") if p != ""] - if 1 <= len(parts) <= 3 and all(re.fullmatch(r"\d{1,2}", p) for p in parts): - hh = int(parts[0]) - mm = int(parts[1]) if len(parts) >= 2 else 0 - ss = int(parts[2]) if len(parts) == 3 else 0 - mm += ss // 60; ss = ss % 60 - hh += mm // 60; mm = mm % 60 - hh = hh % 24 - return f"{hh:02d}:{mm:02d}:{ss:02d}" - - # fallback pandas - try: - t = pd.to_datetime(s0, errors="coerce") - if pd.isna(t): - return "" - return t.strftime("%H:%M:%S") - except Exception: - return "" - - -def conv_excel_time(v): - """ - Converte variados formatos para 'HH:MM:SS'. - """ - if v is None or v == "": - return "" - if isinstance(v, str): - return _normalize_hms_str(v) - if isinstance(v, (int, float)): - try: - total_seconds = int(round(float(v) * 86400)) - h = (total_seconds // 3600) % 24 - m = (total_seconds % 3600) // 60 - s = total_seconds % 60 - return f"{h:02d}:{m:02d}:{s:02d}" - except Exception: - return "" - if isinstance(v, time): - return v.strftime("%H:%M:%S") - if isinstance(v, datetime): - return v.strftime("%H:%M:%S") - return "" - - -# ========================================================== -# Modelo oficial: 37 colunas EXATAS (sem ID) -# ========================================================== -OFFICIAL_COLUMNS: List[str] = [ - "HOR. DE CHEGADA NA PORTARIA", - "HOR. DE CHEGADA NO IFS", - "HOR. DE SAÍDA DO IFS/WMS", - "DATA", - "PLACA VEÍCULO", - "TRANSPORTADORA", - "PO", - "INCOTERMS", - "Qtd. SKU", - "NOTA FISCAL", - "FORNECEDOR", - "QUIMICOS", - "FDS", - "NATUREZA DA OPERAÇÃO", - "TIPO DE OPERAÇÃO", - "BARCO", - "DIVERGENCIA", - "IFS", - "WMS", - "FOTOGRAFIA", - "ENTREGA", - "PROJETO", - "REPETRO", - "HOR. LIBERAÇÃO PARA OPERAÇÃO", - "HOR. DE CHEGADA NA OPERAÇÃO", - "HOR. DE SAÍDA DA OPERAÇÃO", - "DATA DE EMISSÃO", - "APPROVED?", - "GOOD RECEIPT", - "HORA DE RETORNO DA OPERAÇÃO", - "DIVERGÊNCIA RECEBIMENTO", - "HORA DA LIBERAÇÃO DO MOTORISTA", - "QUALIDADE", - "DIVERGÊNCIA QUALIDADE", - "OBSERVAÇÃO", - "AGENDAMENTO", - "RESPONSÁVEL", -] - -# Colunas de horário para normalização visual -TIME_COLUMNS = [ - "HOR. DE CHEGADA NA PORTARIA", - "HOR. DE CHEGADA NO IFS", - "HOR. DE SAÍDA DO IFS/WMS", - "HOR. LIBERAÇÃO PARA OPERAÇÃO", - "HOR. DE CHEGADA NA OPERAÇÃO", - "HOR. DE SAÍDA DA OPERAÇÃO", - "HORA DE RETORNO DA OPERAÇÃO", - "HORA DA LIBERAÇÃO DO MOTORISTA", -] - -# Colunas opcionais aceitas -OPTIONAL_COLUMNS: List[str] = ["ID", "P.O", "PN", "LOT BATCH"] - -# Mapeamento Excel → campos do modelo -COLUMN_MAP: Dict[str, Tuple[str, Optional[Any]]] = { - "ID": ("id_planilha", lambda v: int(v) if str(v).strip().isdigit() else None), - - "HOR. DE CHEGADA NA PORTARIA": ("hora_chegada_portaria", conv_excel_time), - "HOR. DE CHEGADA NO IFS": ("hora_chegada_ifs", conv_excel_time), - "HOR. DE SAÍDA DO IFS/WMS": ("hora_saida_ifs_wms", conv_excel_time), - "DATA": ("data", conv_date), - "PLACA VEÍCULO": ("placa_veiculo", None), - "TRANSPORTADORA": ("transportadora", None), - "PO": ("po", None), - "INCOTERMS": ("incoterms", None), - "Qtd. SKU": ("qtd_sku", lambda v: int(v) if str(v).strip().isdigit() else None), - "NOTA FISCAL": ("nota_fiscal", None), - "FORNECEDOR": ("fornecedor", None), - "QUIMICOS": ("quimicos", conv_bool), - "FDS": ("fds", conv_bool), - "NATUREZA DA OPERAÇÃO": ("natureza_operacao", None), - "TIPO DE OPERAÇÃO": ("tipo_operacao", None), - "BARCO": ("barco", None), - - "DIVERGENCIA": ("divergencia", None), - "NF DIVERGENTE": ("divergencia", None), - - "IFS": ("ifs", None), - "WMS": ("wms", None), - "FOTOGRAFIA": ("fotografia", None), - "ENTREGA": ("entrega", None), - "PROJETO": ("projeto", None), - "REPETRO": ("repetro", conv_bool), - "HOR. LIBERAÇÃO PARA OPERAÇÃO": ("hora_liberacao_operacao", conv_excel_time), - "HOR. DE CHEGADA NA OPERAÇÃO": ("hora_chegada_operacao", conv_excel_time), - "HOR. DE SAÍDA DA OPERAÇÃO": ("hora_saida_operacao", conv_excel_time), - "DATA DE EMISSÃO": ("data_emissao", conv_date), - "APPROVED?": ("aprovado", conv_bool), - "GOOD RECEIPT": ("good_receipt", None), - "HORA DE RETORNO DA OPERAÇÃO": ("hora_retorno_operacao", conv_excel_time), - "DIVERGÊNCIA RECEBIMENTO": ("divergencia_recebimento", None), - "HORA DA LIBERAÇÃO DO MOTORISTA": ("hora_liberacao_motorista", conv_excel_time), - "QUALIDADE": ("qualidade", None), - "DIVERGÊNCIA QUALIDADE": ("divergencia_qualidade", None), - "OBSERVAÇÃO": ("observacao", None), - "AGENDAMENTO": ("agendamento", None), - "RESPONSÁVEL": ("responsavel", None), - - "P.O": ("po_alt", None), - "PN": ("pn", None), - "LOT BATCH": ("lot_batch", None), -} - -IGNORED_COLS = {"ID.PO2", "Unnamed: 1", "Unnamed: 40", "Unnamed: 41", "Unnamed: 42"} - - -# ========================================================== -# Funções de banco / ID automático -# ========================================================== -def _get_max_id_planilha() -> int: - db = _get_db_session() - try: - res = db.query(RecebimentoRegistro.id_planilha).order_by(RecebimentoRegistro.id_planilha.desc()).first() - return int(res[0]) if res and res[0] is not None else 0 - finally: - db.close() - - -def _next_id_planilha() -> int: - return _get_max_id_planilha() + 1 - - -def get_ultimo() -> Optional[RecebimentoRegistro]: - db = _get_db_session() - try: - return db.query(RecebimentoRegistro).order_by(RecebimentoRegistro.id.desc()).first() - finally: - db.close() - - -def sugestao_defaults() -> Dict[str, Any]: - u = get_ultimo() - if not u: - return {} - return { - "transportadora": u.transportadora, - "incoterms": u.incoterms, - "natureza_operacao": u.natureza_operacao, - "tipo_operacao": u.tipo_operacao, - "barco": u.barco, - "ifs": u.ifs, - "wms": u.wms, - "entrega": u.entrega, - "projeto": u.projeto, - "qualidade": u.qualidade, - "agendamento": u.agendamento, - "responsavel": u.responsavel, - } - - -# ========================================================== -# Helpers de perfil/admin -# ========================================================== -def _is_admin() -> bool: - return (st.session_state.get("perfil") or "").lower() == "admin" - - -# ========================================================== -# PIN (Admin) — com key_prefix para evitar IDs duplicados -# ========================================================== -PIN_KEY = "__PIN_RECEBIMENTO__" - -def _pin_info(): - return st.session_state.get(PIN_KEY, None) - -def _pin_is_valid() -> bool: - info = _pin_info() - if not info: - return False - if datetime.utcnow() >= info["exp"]: - st.session_state.pop("__PIN_OK__", None) - return False - return True - -def admin_pin_area(key_prefix: str = "__pin_admin__"): - """ - Renderiza a área de PIN somente se perfil == 'admin'. - Para não-admin: não renderiza nada. - """ - if not _is_admin(): - return - - with st.expander("🔐 Configurar PIN de edição (somente admin)", expanded=False): - col1, col2, col3 = st.columns([1, 1, 2]) - ttl_min = col1.number_input( - "Validade (min)", - min_value=1, max_value=120, value=15, step=1, - key=f"{key_prefix}__ttl_min__" - ) - if col2.button("Gerar PIN automático", key=f"{key_prefix}__btn_pin__"): - pin = f"{secrets.randbelow(10**6):06d}" - exp = datetime.utcnow() + timedelta(minutes=int(ttl_min)) - st.session_state[PIN_KEY] = {"pin": pin, "exp": exp} - st.session_state.pop("__PIN_OK__", None) - st.success(f"PIN gerado: {pin} (expira em {ttl_min} min)") - info = _pin_info() - if info: - restante = int((info["exp"] - datetime.utcnow()).total_seconds()) - mins = max(0, restante // 60) - secs = max(0, restante % 60) - col3.info(f"PIN atual: **{info['pin']}** | Expira em **{mins:02d}:{secs:02d}** (UTC)") - -def validar_pin(key_prefix: str = "__pin_val__") -> bool: - """ - Para admin: renderiza campo de PIN e valida. - Para não-admin: não renderiza nada e retorna False. - """ - if not _is_admin(): - return False - - if not _pin_is_valid(): - st.error("PIN ausente ou expirado. Solicite ao Admin um novo PIN.") - return False - if not st.session_state.get("__PIN_OK__", False): - entrada = st.text_input("Digite o PIN", type="password", key=f"{key_prefix}__pin_in__") - if st.button("Validar PIN", key=f"{key_prefix}__btn_validar_pin__"): - info = _pin_info() - if info and entrada == info["pin"] and _pin_is_valid(): - st.session_state["__PIN_OK__"] = True - st.success("PIN validado!") - else: - st.error("PIN incorreto ou expirado.") - return st.session_state.get("__PIN_OK__", False) - - -# ========================================================== -# Login de Admin + Reset do banco -# ========================================================== -def admin_login_area() -> bool: - """ - Exibe um formulário de login de admin (usuário/senha) usando st.secrets. - Se as credenciais baterem, seta perfil='admin' e retorna True. - """ - if _is_admin(): - st.success(f"Logado como admin: {st.session_state.get('usuario', 'admin')}") - colA, colB = st.columns([1, 1]) - if colA.button("Sair da sessão admin", key="__btn_logout_admin__"): - st.session_state.pop("perfil", None) - st.session_state.pop("__PIN_OK__", None) - st.rerun() - return True - - admin_user = st.secrets.get("ADMIN_USER", None) - admin_pass = st.secrets.get("ADMIN_PASS", None) - if not admin_user or not admin_pass: - with st.expander("Como configurar o login de admin", expanded=False): - st.info( - "Defina as credenciais em `.streamlit/secrets.toml`:\n\n" - "ADMIN_USER = \"admin\"\n" - "ADMIN_PASS = \"sua_senha_forte\"\n" - ) - - st.subheader("🔐 Login de Administrador") - with st.form("admin_login_form", clear_on_submit=False): - u = st.text_input("Usuário admin", placeholder="ex.: admin", key="__admin_user__") - p = st.text_input("Senha admin", type="password", placeholder="********", key="__admin_pass__") - ok = st.form_submit_button("Entrar", type="primary") - - if ok: - admin_user = st.secrets.get("ADMIN_USER", "") - admin_pass = st.secrets.get("ADMIN_PASS", "") - if u == admin_user and p == admin_pass and u != "" and p != "": - st.session_state["perfil"] = "admin" - st.session_state["usuario"] = u - st.success("Login admin realizado com sucesso.") - st.rerun() - else: - st.error("Credenciais inválidas.") - return False - - return _is_admin() - - -def _get_db(): - return _get_db_session() - - -def _fetch_all_recebimentos_df() -> pd.DataFrame: - """ - Retorna todos os registros de RecebimentoRegistro em DataFrame (snake_case), - útil para backup antes do reset. - """ - db = _get_db() - try: - regs = ( - db.query(RecebimentoRegistro) - .order_by(RecebimentoRegistro.created_at.asc()) - .all() - ) - if not regs: - return pd.DataFrame() - return pd.DataFrame([{c.name: getattr(r, c.name) for c in r.__table__.columns} for r in regs]) - finally: - db.close() - - -def reset_recebimento_registros() -> Tuple[int, Optional[str]]: - """ - Apaga TODOS os registros de RecebimentoRegistro. - Retorna (qtd_apagados, erro_str_ou_None). - """ - db = _get_db() - try: - qtd = db.query(RecebimentoRegistro).count() - db.query(RecebimentoRegistro).delete(synchronize_session=False) - db.commit() - return qtd, None - except Exception as e: - db.rollback() - return 0, str(e) - finally: - db.close() - - -# ========================================================== -# Deduplicação por conteúdo (chave única) -# ========================================================== -UNIQUE_KEY_FIELDS = [ - "data", "nota_fiscal", "fornecedor", "po", "pn", "lot_batch", "placa_veiculo" -] - -def _make_unique_key_from_values(vals: Dict[str, Any]) -> Optional[str]: - parts = [] - all_empty = True - for f in UNIQUE_KEY_FIELDS: - v = vals.get(f) - if f == "data": - d = conv_date(v) - sv = d.isoformat() if d else "" - else: - sv = (str(v).strip().upper() if v is not None else "") - if sv != "": - all_empty = False - parts.append(sv) - if all_empty: - return None - return "|".join(parts) - -def _fetch_existing_unique_keys() -> set: - """Busca no banco os campos necessários e monta o set de chaves únicas existentes.""" - db = _get_db() - try: - rows = db.query( - RecebimentoRegistro.data, - RecebimentoRegistro.nota_fiscal, - RecebimentoRegistro.fornecedor, - RecebimentoRegistro.po, - RecebimentoRegistro.pn, - RecebimentoRegistro.lot_batch, - RecebimentoRegistro.placa_veiculo, - ).all() - keys = set() - for r in rows: - vals = { - "data": r[0], - "nota_fiscal": r[1], - "fornecedor": r[2], - "po": r[3], - "pn": r[4], - "lot_batch": r[5], - "placa_veiculo": r[6], - } - k = _make_unique_key_from_values(vals) - if k: - keys.add(k) - return keys - finally: - db.close() - - -# ========================================================== -# Sanitização ciente por campo -# ========================================================== -DATE_FIELDS = {"data", "data_emissao"} -DATETIME_FIELDS = {"created_at", "updated_at"} -TIME_FIELDS = { - "hora_chegada_portaria", - "hora_chegada_ifs", - "hora_saida_ifs_wms", - "hora_liberacao_operacao", - "hora_chegada_operacao", - "hora_saida_operacao", - "hora_retorno_operacao", - "hora_liberacao_motorista", -} - -def _to_date_py(v) -> Optional[date]: - if v is None: - return None - if isinstance(v, date) and not isinstance(v, datetime): - return v - try: - if not isinstance(v, str) and pd.isna(v): - return None - except Exception: - pass - if isinstance(v, datetime): - return v.date() - try: - dt = pd.to_datetime(v, dayfirst=True, errors="coerce") - if pd.isna(dt): - return None - return dt.date() - except Exception: - return None - -def _to_datetime_py(v) -> Optional[datetime]: - if v is None: - return None - if isinstance(v, datetime): - return v - try: - if not isinstance(v, str) and pd.isna(v): - return None - except Exception: - pass - if isinstance(v, date): - return datetime(v.year, v.month, v.day) - try: - dt = pd.to_datetime(v, errors="coerce") - if pd.isna(dt): - return None - return pd.Timestamp(dt).to_pydatetime() - except Exception: - return None - -def _to_time_str(v) -> str: - if v is None: - return "" - if isinstance(v, str): - return conv_excel_time(v) - return conv_excel_time(v) - -def _sanitize_payload_for_db(payload: Dict[str, Any]) -> Dict[str, Any]: - """ - Converte valores conforme o tipo lógico da coluna: - - DATE_FIELDS -> date Python - - DATETIME_FIELDS -> datetime Python - - TIME_FIELDS -> 'HH:MM:SS' string - Também normaliza NaT/NaN -> None e strings vazias -> None (exceto TIME). - """ - if not payload: - return payload - sanitized: Dict[str, Any] = {} - for k, v in payload.items(): - try: - if not isinstance(v, str) and pd.isna(v): - v = None - except Exception: - pass - - if k in DATE_FIELDS: - val = _to_date_py(v) - elif k in DATETIME_FIELDS: - val = _to_datetime_py(v) - elif k in TIME_FIELDS: - s = _to_time_str(v) - val = s if s != "" else None - else: - if isinstance(v, str) and v.strip() == "": - val = None - else: - val = v - - sanitized[k] = val - return sanitized - - -# ========================================================== -# Persistência (salvar/salvar_lote) -# ========================================================== -_MODEL_COLS = set(RecebimentoRegistro.__table__.columns.keys()) - -def _filter_to_model(payload: Dict[str, Any]) -> Dict[str, Any]: - return {k: v for k, v in payload.items() if k in _MODEL_COLS} - - -def salvar(payload: Dict[str, Any], overwrite: bool = False) -> int: - db = _get_db() - try: - payload = _filter_to_model(payload) - payload = _sanitize_payload_for_db(payload) - - existente = None - if payload.get("id_planilha"): - existente = ( - db.query(RecebimentoRegistro) - .filter(RecebimentoRegistro.id_planilha == payload["id_planilha"]) - .first() - ) - - if existente and not overwrite: - raise ValueError(f"ID {payload['id_planilha']} já existe. Confirme a sobrescrita.") - - if existente and overwrite: - for k, v in payload.items(): - setattr(existente, k, v) - existente.updated_by = st.session_state.get("usuario") - db.commit() - db.refresh(existente) - return existente.id - - novo = RecebimentoRegistro(**payload) - novo.created_by = st.session_state.get("usuario") - db.add(novo) - db.commit() - db.refresh(novo) - return novo.id - except Exception: - db.rollback() - raise - finally: - db.close() - - -def salvar_lote( - payloads: List[Dict[str, Any]], - overwrite_ids: Optional[set] = None, - progress_cb: Optional[Callable[[int, int, int, int], None]] = None -) -> Tuple[int, int, List[str]]: - """ - Salva/atualiza registros em lote, usando uma única sessão e um único commit. - overwrite_ids: ids (id_planilha) que devem ser sobrescritos caso existam. - progress_cb: função chamada a cada item processado: progress_cb(processados, ok, fail, total) - Retorna: (ok, fail, erros) - """ - overwrite_ids = overwrite_ids or set() - ok = fail = 0 - erros: List[str] = [] - total = len(payloads) - - db = _get_db() - try: - for i, p in enumerate(payloads, start=1): - try: - p2 = _filter_to_model(p) - p2 = _sanitize_payload_for_db(p2) - - idp = p2.get("id_planilha") - - if idp and idp in overwrite_ids: - existente = db.query(RecebimentoRegistro).filter(RecebimentoRegistro.id_planilha == idp).first() - if existente: - for k, v in p2.items(): - setattr(existente, k, v) - existente.updated_by = st.session_state.get("usuario") - else: - novo = RecebimentoRegistro(**p2) - novo.created_by = st.session_state.get("usuario") - db.add(novo) - else: - novo = RecebimentoRegistro(**p2) - novo.created_by = st.session_state.get("usuario") - db.add(novo) - - ok += 1 - except Exception as e: - fail += 1 - erros.append(str(e)) - - if progress_cb: - try: - progress_cb(i, ok, fail, total) - except Exception: - pass - - db.commit() - return ok, fail, erros - - except Exception: - db.rollback() - raise - finally: - db.close() - - -# ========================================================== -# Validação do cabeçalho (ordem livre) -# ========================================================== -def _norm(s: str) -> str: - s = unicodedata.normalize("NFKD", str(s)).encode("ASCII", "ignore").decode("ASCII") - s = " ".join(s.split()) - return s.strip().lower() - -# Aliases/sinônimos aceitos -> nome canônico oficial -ALIASES_NORM = { - _norm("NF DIVERGENTE"): "DIVERGENCIA", - _norm("P.O"): "PO", - _norm("QTD SKU"): "Qtd. SKU", - _norm("APROVADO"): "APPROVED?", - _norm("HORA LIBERACAO PARA OPERACAO"): "HOR. LIBERAÇÃO PARA OPERAÇÃO", - _norm("HORA CHEGADA NA OPERACAO"): "HOR. DE CHEGADA NA OPERAÇÃO", - _norm("HORA SAIDA DA OPERACAO"): "HOR. DE SAÍDA DA OPERAÇÃO", - _norm("HORA RETORNO DA OPERACAO"): "HORA DE RETORNO DA OPERAÇÃO", - _norm("HORA LIBERACAO DO MOTORISTA"): "HORA DA LIBERAÇÃO DO MOTORISTA", -} - -def validar_e_reordenar(df: pd.DataFrame) -> Tuple[pd.DataFrame, List[str]]: - df = df[[c for c in df.columns if c not in IGNORED_COLS]].copy() - - official_norm_map = {_norm(col): col for col in OFFICIAL_COLUMNS} - optional_norm_map = {_norm(col): col for col in OPTIONAL_COLUMNS} - - found_map: Dict[str, str] = {} - usados_df = set() - - def resolve_official_name(norm_name: str) -> Optional[str]: - norm_name = ALIASES_NORM.get(norm_name, norm_name) - if norm_name in official_norm_map: - return official_norm_map[norm_name] - return None - - for col_df in df.columns: - norm = _norm(col_df) - oficial = resolve_official_name(norm) - if not oficial and norm in optional_norm_map: - oficial = optional_norm_map[norm] - if oficial and (col_df not in usados_df) and (oficial not in found_map): - found_map[oficial] = col_df - usados_df.add(col_df) - - faltantes = [col for col in OFFICIAL_COLUMNS if col not in found_map] - if faltantes: - return df, faltantes - - final_cols_df = [] - if "ID" in found_map: - final_cols_df.append(found_map["ID"]) - - ordered_df_cols = [found_map[col] for col in OFFICIAL_COLUMNS] - final_cols_df.extend(ordered_df_cols) - - extras = [c for c in df.columns if c not in set(final_cols_df)] - final_cols_df.extend(extras) - - df_ok = df[final_cols_df].copy() - - rename_map = {found_map[col]: col for col in OFFICIAL_COLUMNS if col in found_map} - if "ID" in found_map: - rename_map[found_map["ID"]] = "ID" - df_ok.rename(columns=rename_map, inplace=True) - - return df_ok, [] - - -# ========================================================== -# Excel: leitura (cache por conteúdo) -# ========================================================== -def gerar_modelo_oficial_xlsx() -> bytes: - exemplo = pd.DataFrame(columns=OFFICIAL_COLUMNS) - bio = io.BytesIO() - try: - with pd.ExcelWriter(bio, engine="openpyxl") as xw: - exemplo.to_excel(xw, index=False, sheet_name="Recebimento") - bio.seek(0) - return bio.read() - except Exception: - return b"" - -@st.cache_data(show_spinner=False) -def _read_file_cached(file_bytes: bytes, filename_lower: str) -> pd.DataFrame: - bio = io.BytesIO(file_bytes) - if filename_lower.endswith(".csv"): - return pd.read_csv(bio, sep=None, engine="python") - return pd.read_excel(bio, engine="openpyxl") - - -# ========================================================== -# Linha → payload e duplicidades (NaT-safe) -# ========================================================== -def linha_para_payload(row: pd.Series) -> Dict[str, Any]: - payload: Dict[str, Any] = {} - - def _set(campo: str, valor: Any): - if (not isinstance(valor, str) and pd.isna(valor)) or (isinstance(valor, str) and valor.strip() == ""): - return - if isinstance(valor, time): - valor = valor.strftime("%H:%M:%S") - elif isinstance(valor, datetime): - valor = valor.strftime("%Y-%m-%d %H:%M:%S") - elif isinstance(valor, date) and not isinstance(valor, datetime): - valor = valor.isoformat() - if valor is None or (isinstance(valor, str) and valor.strip() == ""): - return - payload[campo] = valor - - for col_excel, (campo, conv) in COLUMN_MAP.items(): - raw = row.get(col_excel, None) - if conv: - try: - val = conv(raw) - except Exception: - val = None - else: - if raw in ("", "NaN") or (isinstance(raw, float) and pd.isna(raw)): - val = None - else: - val = raw - _set(campo, val) - - return payload - -_CONTENT_FIELDS = [m[0] for k, m in COLUMN_MAP.items() if m[0] != "id_planilha"] - -def _hash_content(payload: Dict[str, Any]) -> str: - key = tuple((k, payload.get(k)) for k in sorted(_CONTENT_FIELDS)) - return hashlib.sha256(repr(key).encode("utf-8")).hexdigest() - -def _duplicados_no_arquivo(payloads: List[Dict[str, Any]]) -> List[int]: - seen = {} - dups = [] - for idx, p in enumerate(payloads): - h = _hash_content(p) - if h in seen: - dups.append(idx) - else: - seen[h] = idx - return dups - -def _iguais_no_banco(payloads: List[Dict[str, Any]]) -> List[int]: - """(Compat; não usado na decisão final)""" - iguais = [] - db = _get_db() - try: - for idx, p in enumerate(payloads): - idp = p.get("id_planilha") - if not idp: - continue - reg = db.query(RecebimentoRegistro).filter(RecebimentoRegistro.id_planilha == idp).first() - if not reg: - continue - campos = [c for c in _CONTENT_FIELDS if hasattr(reg, c)] - equal = all(getattr(reg, campo) == p.get(campo) for campo in campos) - if equal: - iguais.append(idx) - return iguais - finally: - db.close() - -def _atribuir_ids_automaticos(payloads: List[Dict[str, Any]]): - atual_max = _get_max_id_planilha() - usados_arquivo = {p.get("id_planilha") for p in payloads if p.get("id_planilha")} - usados_arquivo.discard(None) - db = _get_db() - try: - usados_db = {r[0] for r in db.query(RecebimentoRegistro.id_planilha) - .filter(RecebimentoRegistro.id_planilha.isnot(None)).all()} - finally: - db.close() - proximo = max(atual_max, *(list(usados_db) or [0])) + 1 - for p in payloads: - if not p.get("id_planilha"): - while proximo in usados_arquivo or proximo in usados_db: - proximo += 1 - p["id_planilha"] = proximo - usados_arquivo.add(proximo) - proximo += 1 - -def _normalize_times_in_df(df: pd.DataFrame) -> pd.DataFrame: - df2 = df.copy() - for col in TIME_COLUMNS: - if col in df2.columns: - df2[col] = df2[col].apply(conv_excel_time) - return df2 - - -# ========================================================== -# Utilitários KPI (relatórios) -# ========================================================== -def _months_in_period(dates: pd.Series) -> int: - if dates.empty: - return 0 - dts = pd.to_datetime(dates, errors="coerce") - dts = dts.dropna() - if dts.empty: - return 0 - return dts.dt.to_period("M").nunique() - -def _daily_count(df_dates: pd.Series) -> int: - dts = pd.to_datetime(df_dates, errors="coerce").dropna() - return dts.nunique() - -def _kpis_metas(total_reg: int, datas: pd.Series, meta_diaria: float, meta_mensal: float): - qtd_dias = _daily_count(datas) - qtd_meses = _months_in_period(datas) - media_dia = (total_reg / qtd_dias) if qtd_dias else 0.0 - alvo_mes_total = (meta_mensal * qtd_meses) if meta_mensal else 0.0 - alvo_dia_total = (meta_diaria * qtd_dias) if meta_diaria else 0.0 - - c1, c2, c3, c4 = st.columns(4) - c1.metric("Total no período", total_reg) - c2.metric("Dias (distintos)", qtd_dias) - c3.metric("Média/dia", f"{media_dia:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")) - if meta_diaria: - perc_dia = (media_dia / meta_diaria * 100.0) if meta_diaria else 0 - c4.metric("Ating. meta diária", f"{perc_dia:.1f}%") - else: - c4.metric("Ating. meta diária", "—") - - c5, c6 = st.columns(2) - if meta_mensal: - ating_mes = (total_reg / alvo_mes_total * 100.0) if alvo_mes_total else 0 - c5.metric("Ating. meta mensal (aj. #meses)", f"{ating_mes:.1f}%") - else: - c5.metric("Ating. meta mensal", "—") - if meta_diaria: - ating_dia_tot = (total_reg / alvo_dia_total * 100.0) if alvo_dia_total else 0 - c6.metric("Ating. total vs soma metas diárias", f"{ating_dia_tot:.1f}%") - else: - c6.metric("Ating. total vs soma metas diárias", "—") - - -# ========================================================== -# Helpers de UI (preview + processamento) -# ========================================================== -def _safe_preview(df: pd.DataFrame, title: str = "Pré-visualização", rows: int = 20): - st.write(f"{title} (até {rows} linhas):") - st.dataframe(df.head(rows), use_container_width=True) - - -def _processar_arquivo( - file_bytes: bytes, - filename_lower: str, - auto_id_missing: bool -) -> Tuple[Optional[pd.DataFrame], Optional[List[Dict[str, Any]]], List[int], List[int], List[int]]: - """ - Pipeline: - 1) leitura - 2) validação/reordenação - 3) normalização de horas (preview) - 4) conversão linha->payload - 5) id automático (opcional) - 6) checagens: duplicados em arquivo, iguais no banco, ids existentes - """ - status = st.status("Iniciando processamento do arquivo...", expanded=True) - try: - status.update(label="📥 Lendo arquivo...", state="running") - df_raw = _read_file_cached(file_bytes, filename_lower) - _safe_preview(df_raw, title="Prévia do arquivo carregado") - - status.update(label="✅ Validando cabeçalho e reordenando...", state="running") - df_valid, faltantes = validar_e_reordenar(df_raw) - if faltantes: - status.update(label="❌ Validação falhou", state="error") - st.error("Importação bloqueada. Coluna(s) obrigatória(s) ausente(s):") - for f in faltantes: - st.markdown(f"- **{f}**") - st.info("Corrija a planilha para prosseguir. Dica: baixe o *Modelo oficial* acima.") - return None, None, [], [], [] - - status.update(label="⏱️ Normalizando colunas de horário para HH:MM:SS...", state="running") - df_preview = _normalize_times_in_df(df_valid) - - status.update(label="🔄 Convertendo linhas para payload...", state="running") - total = len(df_valid) - payloads: List[Dict[str, Any]] = [] - if total > 0: - pbar = st.progress(0, text="Convertendo...") - for i, (_, row) in enumerate(df_valid.iterrows()): - payloads.append(linha_para_payload(row)) - if ((i + 1) % max(1, total // 50) == 0) or (i + 1 == total): - pbar.progress(int(((i + 1) / total) * 100), text=f"Convertendo... {i+1}/{total}") - pbar.empty() - - if auto_id_missing and payloads: - status.update(label="🔢 Atribuindo IDs automáticos (linhas sem ID)...", state="running") - _atribuir_ids_automaticos(payloads) - - status.update(label="🧭 Verificando duplicidades/iguais/IDs existentes...", state="running") - idx_dups_arquivo = _duplicados_no_arquivo(payloads) - idx_iguais_db = _iguais_no_banco(payloads) - - ids_plan = [p.get("id_planilha") for p in payloads if p.get("id_planilha")] - db = _get_db() - try: - existentes = [] - if ids_plan: - existentes = [x[0] for x in db.query(RecebimentoRegistro.id_planilha) - .filter(RecebimentoRegistro.id_planilha.in_(ids_plan)).all()] - finally: - db.close() - - status.update(label="✅ Processamento concluído", state="complete") - return df_preview, payloads, idx_dups_arquivo, idx_iguais_db, existentes - - except Exception as e: - status.update(label="❌ Ocorreu um erro durante o processamento", state="error") - st.exception(e) - return None, None, [], [], [] - - -# ========================================================== -# Formulário manual -# ========================================================== -def formulario(payload: Optional[Dict[str, Any]] = None, key_prefix: str = "new") -> Dict[str, Any]: - sug = sugestao_defaults() - if payload is None: - payload = {} - - id_planilha_auto = payload.get("id_planilha") or _next_id_planilha() - - st.markdown("### 🧾 Cabeçalho") - c1, c2, c3, c4, c5 = st.columns(5) - c1.text_input("ID (planilha) — automático", value=str(id_planilha_auto), disabled=True, key=f"{key_prefix}__id_planilha_view") - data = c2.date_input("Data", value=payload.get("data") or date.today(), key=f"{key_prefix}__data") - data_emissao = c3.date_input("Data de Emissão", value=payload.get("data_emissao") or date.today(), key=f"{key_prefix}__data_emissao") - nf = c4.text_input("Nota Fiscal", value=payload.get("nota_fiscal") or "", key=f"{key_prefix}__nota_fiscal") - fornecedor = c5.text_input("Fornecedor", value=payload.get("fornecedor") or "", key=f"{key_prefix}__fornecedor") - - c6, c7, c8, c9, c10 = st.columns(5) - placa = c6.text_input("Placa do Veículo", value=payload.get("placa_veiculo") or "", key=f"{key_prefix}__placa_veiculo") - trans = c7.text_input("Transportadora", value=payload.get("transportadora") or sug.get("transportadora") or "", key=f"{key_prefix}__transportadora") - po = c8.text_input("PO", value=payload.get("po") or "", key=f"{key_prefix}__po") - incot = c9.text_input("Incoterms", value=payload.get("incoterms") or sug.get("incoterms") or "", key=f"{key_prefix}__incoterms") - qtd = c10.number_input("Qtd. SKU", min_value=0, value=int(payload.get("qtd_sku") or 0), key=f"{key_prefix}__qtd_sku") - - st.markdown("### ⏱️ Horários (HH:MM:SS)") - c11, c12, c13, c14, c15 = st.columns(5) - hc_port = c11.text_input("Chegada Portaria", value=conv_excel_time(payload.get("hora_chegada_portaria")), key=f"{key_prefix}__hora_chegada_portaria") - hc_ifs = c12.text_input("Chegada IFS", value=conv_excel_time(payload.get("hora_chegada_ifs")), key=f"{key_prefix}__hora_chegada_ifs") - hs_ifs = c13.text_input("Saída IFS/WMS", value=conv_excel_time(payload.get("hora_saida_ifs_wms")), key=f"{key_prefix}__hora_saida_ifs_wms") - hlib = c14.text_input("Liberação p/ Operação", value=conv_excel_time(payload.get("hora_liberacao_operacao")), key=f"{key_prefix}__hora_liberacao_operacao") - hch_op = c15.text_input("Chegada Operação", value=conv_excel_time(payload.get("hora_chegada_operacao")), key=f"{key_prefix}__hora_chegada_operacao") - - c16, c17, c18, c19, c20 = st.columns(5) - hs_op = c16.text_input("Saída Operação", value=conv_excel_time(payload.get("hora_saida_operacao")), key=f"{key_prefix}__hora_saida_operacao") - hret = c17.text_input("Retorno Operação", value=conv_excel_time(payload.get("hora_retorno_operacao")), key=f"{key_prefix}__hora_retorno_operacao") - hmot = c18.text_input("Liberação Motorista", value=conv_excel_time(payload.get("hora_liberacao_motorista")), key=f"{key_prefix}__hora_liberacao_motorista") - natureza = c19.text_input("Natureza da Operação", value=payload.get("natureza_operacao") or sug.get("natureza_operacao") or "", key=f"{key_prefix}__natureza_operacao") - tipo_op = c20.text_input("Tipo de Operação", value=payload.get("tipo_operacao") or sug.get("tipo_operacao") or "", key=f"{key_prefix}__tipo_operacao") - - c21, c22, c23, c24, c25 = st.columns(5) - barco = c21.text_input("Barco", value=payload.get("barco") or sug.get("barco") or "", key=f"{key_prefix}__barco") - div = c22.text_input("Divergência", value=payload.get("divergencia") or "", key=f"{key_prefix}__divergencia") - ifs = c23.text_input("IFS", value=payload.get("ifs") or sug.get("ifs") or "", key=f"{key_prefix}__ifs") - wms = c24.text_input("WMS", value=payload.get("wms") or sug.get("wms") or "", key=f"{key_prefix}__wms") - foto = c25.text_input("Fotografia (link/obs.)", value=payload.get("fotografia") or "", key=f"{key_prefix}__fotografia") - - c26, c27, c28, c29, c30 = st.columns(5) - entrega = c26.text_input("Entrega", value=payload.get("entrega") or sug.get("entrega") or "", key=f"{key_prefix}__entrega") - projeto = c27.text_input("Projeto", value=payload.get("projeto") or sug.get("projeto") or "", key=f"{key_prefix}__projeto") - good = c28.text_input("Good Receipt", value=payload.get("good_receipt") or "", key=f"{key_prefix}__good_receipt") - div_rec = c29.text_input("Divergência Recebimento", value=payload.get("divergencia_recebimento") or "", key=f"{key_prefix}__divergencia_recebimento") - qual = c30.text_input("Qualidade", value=payload.get("qualidade") or sug.get("qualidade") or "", key=f"{key_prefix}__qualidade") - - c31, c32, c33, c34, c35 = st.columns(5) - div_qual = c31.text_input("Divergência Qualidade", value=payload.get("divergencia_qualidade") or "", key=f"{key_prefix}__divergencia_qualidade") - obs = c32.text_area("Observação", value=payload.get("observacao") or "", height=80, key=f"{key_prefix}__observacao") - agend = c33.text_input("Agendamento", value=payload.get("agendamento") or sug.get("agendamento") or "", key=f"{key_prefix}__agendamento") - resp = c34.text_input("Responsável", value=payload.get("responsavel") or sug.get("responsavel") or "", key=f"{key_prefix}__responsavel") - po_alt = c35.text_input("P.O (alternativo)", value=payload.get("po_alt") or "", key=f"{key_prefix}__po_alt") - - c36, c37, _ = st.columns(3) - pn = c36.text_input("PN", value=payload.get("pn") or "", key=f"{key_prefix}__pn") - lot_batch = c37.text_input("LOT BATCH", value=payload.get("lot_batch") or "", key=f"{key_prefix}__lot_batch") - - st.markdown("### ✅ Sinalizações") - c36b, c37b, c38b, c39b = st.columns(4) - quimicos = c36b.selectbox("Químicos", ["SIM", "NÃO", "N/A"], - index=["SIM","NÃO","N/A"].index("SIM" if payload.get("quimicos") is True else "NÃO" if payload.get("quimicos") is False else "N/A"), - key=f"{key_prefix}__quimicos") - fds = c37b.selectbox("FDS", ["SIM", "NÃO", "N/A"], - index=["SIM","NÃO","N/A"].index("SIM" if payload.get("fds") is True else "NÃO" if payload.get("fds") is False else "N/A"), - key=f"{key_prefix}__fds") - repetro = c38b.selectbox("Repetro", ["SIM", "NÃO", "N/A"], - index=["SIM","NÃO","N/A"].index("SIM" if payload.get("repetro") is True else "NÃO" if payload.get("repetro") is False else "N/A"), - key=f"{key_prefix}__repetro") - aprovado = c39b.selectbox("Aprovado", ["SIM", "NÃO", "N/A"], - index=["SIM","NÃO","N/A"].index("SIM" if payload.get("aprovado") is True else "NÃO" if payload.get("aprovado") is False else "N/A"), - key=f"{key_prefix}__aprovado") - - return { - "id_planilha": (payload.get("id_planilha") or _next_id_planilha()), - "data": data, - "data_emissao": data_emissao, - "nota_fiscal": nf, - "fornecedor": fornecedor, - "placa_veiculo": placa, - "transportadora": trans, - "po": po, - "incoterms": incot, - "qtd_sku": qtd, - - "hora_chegada_portaria": hc_port, - "hora_chegada_ifs": hc_ifs, - "hora_saida_ifs_wms": hs_ifs, - "hora_liberacao_operacao": hlib, - "hora_chegada_operacao": hch_op, - "hora_saida_operacao": hs_op, - "hora_retorno_operacao": hret, - "hora_liberacao_motorista": hmot, - - "natureza_operacao": natureza, - "tipo_operacao": tipo_op, - "barco": barco, - "divergencia": div, - - "ifs": ifs, - "wms": wms, - "fotografia": foto, - "entrega": entrega, - "projeto": projeto, - "good_receipt": good, - "divergencia_recebimento": div_rec, - "qualidade": qual, - "divergencia_qualidade": div_qual, - "observacao": obs, - "agendamento": agend, - "responsavel": resp, - - "po_alt": po_alt, - "pn": pn, - "lot_batch": lot_batch, - - "quimicos": conv_bool(quimicos), - "fds": conv_bool(fds), - "repetro": conv_bool(repetro), - "aprovado": conv_bool(aprovado), - } - - -# ========================================================== -# 🚪 Portão Manual do Módulo -# ========================================================== -def recebimento_manual_gate() -> bool: - st.session_state.setdefault("__rec_allow__", False) - st.session_state.setdefault("__upl_file_bytes__", None) - st.session_state.setdefault("__upl_filename__", None) - st.session_state.setdefault("__df_preview__", None) - st.session_state.setdefault("__payloads_ready__", None) - st.session_state.setdefault("__existentes__", []) - st.session_state.setdefault("__idx_dups__", []) - st.session_state.setdefault("__idx_iguais_db__", []) - st.session_state.setdefault("__import_auto_id__", True) - - st.markdown("### 🔒 Modo manual — Recebimento") - st.caption("Este módulo **não executa automaticamente**. Use os botões abaixo para controlar a execução.") - with st.form("rec_gate_form", clear_on_submit=False): - c1, c2, c3 = st.columns([1, 1, 1]) - run_now = c1.form_submit_button("▶️ Executar módulo agora", type="primary", use_container_width=True) - freeze = c2.form_submit_button("🧊 Congelar (parar atualizações)", use_container_width=True) - reset = c3.form_submit_button("🔄 Recarregar & limpar prévias", use_container_width=True) - - if run_now: - st.session_state["__rec_allow__"] = True - - if freeze: - st.session_state["__rec_allow__"] = False - - if reset: - st.session_state["__upl_file_bytes__"] = None - st.session_state["__upl_filename__"] = None - st.session_state["__df_preview__"] = None - st.session_state["__payloads_ready__"] = None - st.session_state["__existentes__"] = [] - st.session_state["__idx_dups__"] = [] - st.session_state["__idx_iguais_db__"] = [] - st.session_state["__rec_allow__"] = False - st.info("Estados limpos. Módulo congelado. Clique em **▶️ Executar módulo agora** para rodar novamente.") - - return bool(st.session_state["__rec_allow__"]) - - -# ========================================================== -# Interface principal -# ========================================================== -def main(): - st.title("📦 Recebimento — Planilha Oficial (Modo Manual)") - - allowed = recebimento_manual_gate() - if not allowed: - st.stop() - - aba_form, aba_import, aba_reg, aba_rel, aba_admin = st.tabs([ - "Formulário Manual", "Importar Excel", "Registros", "Relatórios", "Admin" - ]) - - # ------------------ FORMULÁRIO ------------------ - with aba_form: - st.header("Novo Registro") - if _is_admin(): - admin_pin_area(key_prefix="__pin_top__") - - data_novo = formulario(key_prefix="new") - if st.button("💾 Salvar novo", type="primary", key="__btn_salvar_manual__"): - status_save = st.status("⏳ Ainda processando...", expanded=False, state="running") - try: - novo_id = salvar(data_novo, overwrite=False) - status_save.update(label=f"✅ Registro criado (ID interno: {novo_id}) — ID (planilha): {data_novo['id_planilha']}", state="complete") - st.success(f"Registro criado (ID interno: {novo_id}) — ID (planilha): {data_novo['id_planilha']}") - except Exception as e: - status_save.update(label="❌ Falha ao salvar", state="error") - st.error(str(e)) - - st.divider() - st.header("Editar Registro Existente (por ID da planilha)") - c1, c2 = st.columns([1, 3]) - buscar = c1.number_input("ID da Planilha", min_value=0, key="edit__id_busca") - if c2.button("🔎 Carregar", key="__btn_buscar__"): - db = _get_db() - try: - reg = db.query(RecebimentoRegistro).filter(RecebimentoRegistro.id_planilha == buscar).first() - if not reg: - st.error("ID não encontrado.") - else: - payload = {c.name: getattr(reg, c.name) for c in reg.__table__.columns} - st.session_state["__edicao__"] = payload - st.success("Registro carregado.") - finally: - db.close() - - if st.session_state.get("__edicao__"): - st.subheader("Edição") - if _is_admin(): - pronto = validar_pin(key_prefix="__pin_val_edit__") - payload_edit = formulario(st.session_state["__edicao__"], key_prefix="edit") - if pronto and st.button("💾 Salvar alterações (sobrescrever)", key="__btn_salvar_edicao__"): - status_save = st.status("⏳ Ainda processando...", expanded=False, state="running") - try: - salvar(payload_edit, overwrite=True) - status_save.update(label="✅ Registro atualizado!", state="complete") - st.success("Registro atualizado!") - st.session_state.pop("__edicao__", None) - except Exception as e: - status_save.update(label="❌ Falha ao salvar alterações", state="error") - st.error(str(e)) - elif not pronto: - st.info("Valide um PIN ativo para autorizar a sobrescrita.") - else: - _ = formulario(st.session_state["__edicao__"], key_prefix="edit_readonly") - st.warning("A edição com sobrescrita requer perfil de administrador.") - - # ------------------ IMPORTAÇÃO ------------------ - with aba_import: - st.header("Importação de Arquivo Oficial") - st.caption("Nada é processado automaticamente. Use os formulários e botões abaixo.") - - col_left, col_right = st.columns([2, 1]) - - # FORM 1 — Seleção/Opções - with col_left.form("form_import_select", clear_on_submit=False): - upload = st.file_uploader( - "Selecione .xlsx ou .csv (sem processamento automático)", - type=["xlsx", "csv"], - key="__upl__" - ) - auto_id_missing = st.checkbox( - "Gerar ID automático p/ linhas sem ID", - value=st.session_state.get("__import_auto_id__", True), - key="__ck_autoid_form__" - ) - submit_carregar = st.form_submit_button("📦 Carregar para pré-processo", use_container_width=True) - if submit_carregar: - if upload is None: - st.warning("Selecione um arquivo primeiro.") - else: - st.session_state["__upl_file_bytes__"] = upload.getvalue() - st.session_state["__upl_filename__"] = upload.name - st.session_state["__import_auto_id__"] = bool(auto_id_missing) - st.success(f"Arquivo **{upload.name}** carregado. Agora clique em **⚙️ Processar agora** quando desejar.") - - with col_right: - st.download_button( - "📥 Modelo oficial (.xlsx)", - data=gerar_modelo_oficial_xlsx(), - file_name="modelo_recebimento_oficial.xlsx", - help="Baixe o layout exato esperado pela base (37 colunas; 'ID' é opcional).", - key="__btn_modelo__" - ) - - st.divider() - - # FORM 2 — Processar / Limpar prévia - with st.form("form_import_process", clear_on_submit=False): - b1, b2 = st.columns([1, 1]) - submit_processar = b1.form_submit_button("⚙️ Processar agora") - submit_reprocessar = b2.form_submit_button("🔁 Reprocessar (limpar prévia)") - - if submit_reprocessar: - st.session_state["__df_preview__"] = None - st.session_state["__payloads_ready__"] = None - st.session_state["__existentes__"] = [] - st.session_state["__idx_dups__"] = [] - st.session_state["__idx_iguais_db__"] = [] - st.info("Prévia descartada. Clique em **⚙️ Processar agora** para gerar novamente (usando o arquivo carregado).") - - if submit_processar: - file_bytes = st.session_state.get("__upl_file_bytes__", None) - filename_lower = (st.session_state.get("__upl_filename__", "") or "").lower() - auto_id = st.session_state.get("__import_auto_id__", True) - - if not file_bytes: - st.warning("Nenhum arquivo carregado. Use o formulário acima para carregar um arquivo.") - else: - df_preview, payloads, idx_dups, idx_iguais_db, existentes = _processar_arquivo( - file_bytes=file_bytes, - filename_lower=filename_lower, - auto_id_missing=auto_id - ) - if (df_preview is not None) and (payloads is not None): - st.session_state["__df_preview__"] = df_preview - st.session_state["__payloads_ready__"] = payloads - st.session_state["__existentes__"] = existentes - st.session_state["__idx_dups__"] = idx_dups - st.session_state["__idx_iguais_db__"] = idx_iguais_db - - if st.session_state.get("__df_preview__") is not None: - _safe_preview( - st.session_state["__df_preview__"], - title="Pré-visualização validada (horários normalizados)" - ) - - st.divider() - st.subheader("Salvar no banco (manual)") - - payloads = st.session_state.get("__payloads_ready__") - idx_dups_arquivo = st.session_state.get("__idx_dups__", []) - existentes = st.session_state.get("__existentes__", []) - - if payloads is None: - st.info("⏳ Ainda sem dados prontos para salvar. Use **⚙️ Processar agora** para preparar os dados.") - else: - # --- Cálculo de novos x ignorados com motivos --- - dups_arquivo_set = set(idx_dups_arquivo) - existentes_set = set(existentes) - existing_keys = _fetch_existing_unique_keys() - - payloads_to_save: List[Dict[str, Any]] = [] - ignored_rows: List[Dict[str, Any]] = [] - - skip_by_file = skip_by_id = skip_by_key = 0 - for i, p in enumerate(payloads): - motivo = None - if i in dups_arquivo_set: - motivo = "Duplicado no arquivo" - idp = p.get("id_planilha") - if motivo is None and idp and idp in existentes_set: - motivo = "ID da planilha já existente no banco" - key = _make_unique_key_from_values(p) - if motivo is None and key and key in existing_keys: - motivo = "Conteúdo já importado (chave única)" - - if motivo: - ignored_rows.append({ - "idx": i, - "Motivo": motivo, - "ID": idp, - "Data": p.get("data"), - "NF": p.get("nota_fiscal"), - "Fornecedor": p.get("fornecedor"), - "PO": p.get("po"), - "PN": p.get("pn"), - "LOT BATCH": p.get("lot_batch"), - "Placa": p.get("placa_veiculo"), - "_key": key, - }) - if motivo == "Duplicado no arquivo": - skip_by_file += 1 - elif motivo == "ID da planilha já existente no banco": - skip_by_id += 1 - elif motivo.startswith("Conteúdo já importado"): - skip_by_key += 1 - else: - payloads_to_save.append(p) - - total_original = len(payloads) - total_novos = len(payloads_to_save) - total_skip = total_original - total_novos - - if skip_by_file: - st.warning(f"⚠️ {skip_by_file} linha(s) duplicada(s) no arquivo foram ignoradas.") - if skip_by_id: - st.warning(f"🔁 {skip_by_id} linha(s) ignorada(s) por ID (planilha) já existente no banco.") - if skip_by_key: - st.warning(f"🚫 {skip_by_key} linha(s) ignorada(s) por conteúdo já importado (chave: Data+NF+Fornecedor+PO+PN+LOT BATCH+Placa).") - - st.info(f"📊 Resumo: **{total_novos} novas** serão importadas | **{total_skip}** ignoradas de **{total_original}** total.") - - # ---------- 🔎 Analisar linhas ignoradas (opcional) ---------- - st.divider() - st.subheader("🔎 Analisar linhas ignoradas (opcional)") - - payloads_to_force: List[Dict[str, Any]] = [] - overwrite_ids_force: set = set() - - if not ignored_rows: - st.caption("Sem linhas ignoradas.") - else: - df_ign = pd.DataFrame(ignored_rows) - with st.expander("Ver linhas ignoradas e motivos", expanded=False): - st.dataframe(df_ign.drop(columns=["_key"]), use_container_width=True) - - # Construir opções legíveis para seleção - options = [] - label_map = {} - for r in ignored_rows: - label = f"Linha {r['idx']+1} | {r['Motivo']} | ID={r['ID'] or '—'} | Data={r['Data'] or '—'} | NF={r['NF'] or '—'} | Forn={r['Fornecedor'] or '—'} | PO={r['PO'] or '—'}" - options.append(label) - label_map[label] = r - - sel = st.multiselect("Selecione as linhas ignoradas que deseja incluir mesmo assim:", options, key="__sel_ignored__") - - # Ações para conflitos de ID - actions_id = {} - for label in sel: - r = label_map[label] - if r["ID"] and r["ID"] in existentes_set: - actions_id[r["idx"]] = st.selectbox( - f"Ação p/ Linha {r['idx']+1} (ID {r['ID']}):", - ["Gerar novo ID", "Sobrescrever (admin + PIN)"], - key=f"__ign_act_{r['idx']}" - ) - - # Permitir duplicidade por chave (se houver esse motivo selecionado) - has_key_dup = any(label_map[l]["Motivo"].startswith("Conteúdo já importado") for l in sel) - allow_dup_key = True - if has_key_dup: - allow_dup_key = st.checkbox( - "✅ Permitir inserir mesmo com conteúdo já importado (pode gerar duplicidade)", - value=False, key="__allow_dup_key__" - ) - - # Preparar payloads selecionados + tratar conflitos - for label in sel: - r = label_map[label] - idx = r["idx"] - p = dict(payloads[idx]) # cópia - - # Conteúdo já importado — exige confirmação - if r["Motivo"].startswith("Conteúdo já importado") and not allow_dup_key: - continue # não incluir sem a confirmação - - # ID existente — agir conforme seleção - if r["ID"] and r["ID"] in existentes_set: - act = actions_id.get(idx, "Gerar novo ID") - if act == "Gerar novo ID": - p["id_planilha"] = None # será atribuído automático - else: - overwrite_ids_force.add(r["ID"]) - - payloads_to_force.append(p) - - # ---------- Botões de salvar ---------- - st.divider() - modo_rapido = st.checkbox("⚡ Modo rápido (salvar em lote, único commit)", value=True, key="__ck_modo_rapido__") - - can_save_new = len(payloads_to_save) > 0 - can_save_all = (len(payloads_to_save) + len(payloads_to_force)) > 0 - - csave, csave_all, cclr = st.columns([1, 2, 1]) - submit_save = csave.button("💾 Salvar apenas **novas**", disabled=not can_save_new, type="primary", key="__btn_save_new__") - submit_save_all = csave_all.button("💾 Salvar **TUDO** (novas + selecionadas das ignoradas)", disabled=not can_save_all, type="primary", key="__btn_save_all__") - clear_preview = cclr.button("🧹 Limpar prévia", key="__btn_clear_prev__") - - def _do_save(payloads_ok: List[Dict[str, Any]], overwrite_ids: set): - status_save = st.status("⏳ Ainda processando...", expanded=False, state="running") - contador_ph = st.empty() - pbar_save = st.progress(0, text="Iniciando gravação...") - - def _progress_cb(processados: int, ok: int, fail: int, total: int): - pct = int((processados / total) * 100) if total else 0 - pbar_save.progress(pct, text=f"Gravando... {processados}/{total}") - contador_ph.markdown(f"**Progresso:** {processados}/{total} | **OK:** {ok} | **Falhas:** {fail}") - - try: - ok, fail, erros = (0, 0, []) - if modo_rapido: - ok, fail, erros = salvar_lote(payloads_ok, overwrite_ids=set(overwrite_ids), progress_cb=_progress_cb) - else: - total_local = len(payloads_ok) - for i, p in enumerate(payloads_ok, start=1): - try: - overwrite = (p.get("id_planilha") in overwrite_ids) if p.get("id_planilha") else False - salvar(p, overwrite=overwrite) - ok += 1 - except Exception as e: - fail += 1 - st.write(f"Erro na linha {i}: {e}") - _progress_cb(i, ok, fail, total_local) - - if erros: - with st.expander("Detalhes de erros", expanded=False): - for i, msg in enumerate(erros, 1): - st.write(f"{i:02d}. {msg}") - - status_save.update(label=f"✅ Importação concluída — {ok} OK, {fail} falhas.", state="complete") - st.success(f"Importação concluída — {ok} OK, {fail} falhas.") - except Exception as e: - status_save.update(label="❌ Falha durante a importação", state="error") - st.exception(e) - finally: - pbar_save.empty() - # Limpar estados - st.session_state["__df_preview__"] = None - st.session_state["__payloads_ready__"] = None - st.session_state["__existentes__"] = [] - st.session_state["__idx_dups__"] = [] - st.session_state["__idx_iguais_db__"] = [] - - if submit_save: - # Apenas novas - _do_save(payloads_to_save, overwrite_ids=set()) - - if submit_save_all: - # Preparar conjunto final: novas + selecionadas das ignoradas - payloads_all = list(payloads_to_save) + list(payloads_to_force) - - # Atribuir IDs automáticos para os que ficaram sem ID (apenas no conjunto final) - if any(p.get("id_planilha") in (None, "") for p in payloads_all): - _atribuir_ids_automaticos(payloads_all) - - # Se houver sobrescritas, exigir admin + PIN - overwrite_ids_all = set(overwrite_ids_force) - if overwrite_ids_all: - if not _is_admin(): - st.error("Sobrescrever registros requer perfil de **Administrador**.") - else: - pronto = validar_pin(key_prefix="__pin_val_save_all__") - if not pronto: - st.warning("Valide um PIN ativo para autorizar sobrescritas e tente novamente.") - else: - _do_save(payloads_all, overwrite_ids=overwrite_ids_all) - else: - _do_save(payloads_all, overwrite_ids=set()) - - if clear_preview: - st.session_state["__df_preview__"] = None - st.session_state["__payloads_ready__"] = None - st.session_state["__existentes__"] = [] - st.session_state["__idx_dups__"] = [] - st.session_state["__idx_iguais_db__"] = [] - st.info("Prévia descartada. Clique em **⚙️ Processar agora** para gerar novamente.") - - # ------------------ LISTA ------------------ - with aba_reg: - st.header("Registros (com filtros)") - - db = _get_db() - try: - regs = ( - db.query(RecebimentoRegistro) - .order_by(RecebimentoRegistro.created_at.desc()) - .limit(5000) - .all() - ) - finally: - db.close() - - if not regs: - st.info("Nenhum registro encontrado.") - st.stop() - - df_base = pd.DataFrame([{c.name: getattr(r, c.name) for c in r.__table__.columns} for r in regs]) - - if not df_base.empty and "data" in df_base.columns: - df_base["data"] = pd.to_datetime(df_base["data"], errors="coerce").dt.date - - def rotulo_to_campo(rotulo: str) -> Optional[str]: - info = COLUMN_MAP.get(rotulo) - return info[0] if info else None - - ordem_rotulos = [] - if "ID" in OPTIONAL_COLUMNS: - ordem_rotulos.append("ID") - ordem_rotulos.extend(OFFICIAL_COLUMNS) - - colunas_display = [] - for rot in ordem_rotulos: - campo = rotulo_to_campo(rot) - if campo and campo in df_base.columns: - colunas_display.append((rot, campo)) - - df_disp = pd.DataFrame() - for rot, campo in colunas_display: - df_disp[rot] = df_base[campo] - - usados = {campo for _rot, campo in colunas_display} - extras = [c for c in df_base.columns if c not in usados] - for extra in extras: - df_disp[extra] = df_base[extra] - - with st.expander("🔎 Filtros", expanded=True): - c1, c2, c3 = st.columns(3) - f_po = c1.text_input("P.O (campo: po)", placeholder="ex.: 4500...", key="reg__f_po") - f_pn = c2.text_input("PN (campo: pn)", placeholder="ex.: 1Z23...", key="reg__f_pn") - f_lot = c3.text_input("LOT BATCH (campo: lot_batch)", placeholder="ex.: L123...", key="reg__f_lot") - - c4, c5, c6 = st.columns(3) - f_nf = c4.text_input("Nota Fiscal (campo: nota_fiscal)", placeholder="ex.: 12345", key="reg__f_nf") - f_forn = c5.text_input("Fornecedor (campo: fornecedor)", placeholder="ex.: ACME", key="reg__f_forn") - f_placa = c6.text_input("Placa do Veículo (campo: placa_veiculo)", placeholder="ex.: ABC1D23", key="reg__f_placa") - - c7, c8, c9 = st.columns([1, 1, 1]) - f_data_ini = c7.date_input("Data inicial (campo: data)", value=None, key="reg__f_data_ini") - f_data_fim = c8.date_input("Data final (campo: data)", value=None, key="reg__f_data_fim") - limpar = c9.button("Limpar filtros", key="reg__btn_limpar_filtros") - - if limpar: - for k in ["reg__f_po", "reg__f_pn", "reg__f_lot", "reg__f_nf", "reg__f_forn", "reg__f_placa", "reg__f_data_ini", "reg__f_data_fim"]: - if k in st.session_state: - del st.session_state[k] - st.rerun() - - df_filtrado = df_base.copy() - - def _contains(df, col, term): - if not term or col not in df.columns: - return pd.Series([True] * len(df)) - return df[col].astype(str).str.contains(str(term), case=False, na=False) - - if f_po: df_filtrado = df_filtrado[_contains(df_filtrado, "po", f_po)] - if f_pn: df_filtrado = df_filtrado[_contains(df_filtrado, "pn", f_pn)] - if f_lot: df_filtrado = df_filtrado[_contains(df_filtrado, "lot_batch", f_lot)] - if f_nf: df_filtrado = df_filtrado[_contains(df_filtrado, "nota_fiscal", f_nf)] - if f_forn: df_filtrado = df_filtrado[_contains(df_filtrado, "fornecedor", f_forn)] - if f_placa:df_filtrado = df_filtrado[_contains(df_filtrado, "placa_veiculo", f_placa)] - if "data" in df_filtrado.columns: - if f_data_ini: - df_filtrado = df_filtrado[df_filtrado["data"] >= f_data_ini] - if f_data_fim: - df_filtrado = df_filtrado[df_filtrado["data"] <= f_data_fim] - - df_disp_filtrado = pd.DataFrame() - for rot, campo in colunas_display: - if campo in df_filtrado.columns: - df_disp_filtrado[rot] = df_filtrado[campo] - for extra in extras: - if extra in df_filtrado.columns: - df_disp_filtrado[extra] = df_filtrado[extra] - - total_filtrado = len(df_disp_filtrado) - if "DATA" in df_disp_filtrado.columns: - datas_validas = pd.to_datetime(df_disp_filtrado["DATA"], errors="coerce").dt.date.dropna() - if not datas_validas.empty: - st.caption( - f"Exibindo **{total_filtrado}** registro(s). " - f"Primeira data: **{datas_validas.min()}** — Última data: **{datas_validas.max()}**." - ) - else: - st.caption(f"Exibindo **{total_filtrado}** registro(s).") - else: - st.caption(f"Exibindo **{total_filtrado}** registro(s).") - - st.markdown("**Colunas visíveis**") - final_labels_order = list(df_disp_filtrado.columns) - - vis_key = "__cols_visiveis_labels__" - if vis_key not in st.session_state: - st.session_state[vis_key] = set(final_labels_order) - else: - st.session_state[vis_key] = {lbl for lbl in st.session_state[vis_key] if lbl in final_labels_order} - if not st.session_state[vis_key]: - st.session_state[vis_key] = set(final_labels_order) - - def render_columns_selector(title: str, labels: List[str], state_key: str): - container_supported = hasattr(st, "popover") - ctx_mgr = st.popover(title) if container_supported else st.expander(title, expanded=False) - with ctx_mgr: - st.write("Marque as colunas que deseja **exibir**:") - ac1, ac2 = st.columns(2) - if ac1.button("Selecionar tudo"): - st.session_state[state_key] = set(labels) - if ac2.button("Limpar"): - st.session_state[state_key] = set() - - left, right = st.columns(2) - half = (len(labels) + 1) // 2 - for i, lbl in enumerate(labels): - col = left if i < half else right - checked = lbl in st.session_state[state_key] - new_val = col.checkbox(lbl, value=checked, key=f"__chk_col_{state_key}_{lbl}") - if new_val and lbl not in st.session_state[state_key]: - st.session_state[state_key].add(lbl) - if (not new_val) and (lbl in st.session_state[state_key]): - st.session_state[state_key].discard(lbl) - - render_columns_selector("⚙️ Definir colunas", final_labels_order, vis_key) - - visible_labels_sorted = [lbl for lbl in final_labels_order if lbl in st.session_state[vis_key]] - if not visible_labels_sorted: - st.warning("Nenhuma coluna selecionada. Selecione pelo menos uma para visualizar a tabela.") - else: - st.dataframe(df_disp_filtrado[visible_labels_sorted], use_container_width=True) - - cexp1, cexp2 = st.columns([1, 1]) - csv_data = df_disp_filtrado.to_csv(index=False).encode("utf-8-sig") - cexp1.download_button("⬇️ Exportar CSV (filtrados)", data=csv_data, file_name="registros_filtrados.csv") - xlsx_all = _df_to_excel_bytes(df_disp_filtrado) - if xlsx_all: - cexp2.download_button("⬇️ Exportar Excel (filtrados)", data=xlsx_all, file_name="registros_filtrados.xlsx") - else: - cexp2.caption("Excel indisponível (openpyxl ausente).") - - # ------------------ RELATÓRIOS ------------------ - with aba_rel: - st.header("Relatórios") - - db = _get_db() - try: - regs = ( - db.query(RecebimentoRegistro) - .order_by(RecebimentoRegistro.created_at.desc()) - .limit(20000) - .all() - ) - finally: - db.close() - - if not regs: - st.info("Nenhum registro para relatório.") - st.stop() - - rows = [] - for r in regs: - rows.append({ - "Data": getattr(r, "data", None), - "Fornecedor": getattr(r, "fornecedor", None), - "PO": getattr(r, "po", None), - "PO (alt)": getattr(r, "po_alt", None), - "PN": getattr(r, "pn", None), - "LOT BATCH": getattr(r, "lot_batch", None), - "Placa": getattr(r, "placa_veiculo", None), - "Transportadora": getattr(r, "transportadora", None), - "Aprovado": "SIM" if getattr(r, "aprovado", None) is True else ("NÃO" if getattr(r, "aprovado", None) is False else "N/A"), - "Divergência": getattr(r, "divergencia", None), - "SKU": getattr(r, "qtd_sku", None), - "Tipo de Operação": getattr(r, "tipo_operacao", None), - }) - dr = pd.DataFrame(rows) - - dts = pd.to_datetime(dr.get("Data"), errors="coerce") - if dts.notna().any(): - min_d = dts.min().date() - max_d = dts.max().date() - else: - min_d = date.today().replace(day=1) - max_d = date.today() - - dr["Data"] = dts.dt.date - - periodo = st.date_input( - "Período (Data inicial e final)", - value=(min_d, max_d), - help="Selecione uma data inicial e final para filtrar todos os gráficos." - ) - if isinstance(periodo, tuple) and len(periodo) == 2: - d_ini, d_fim = periodo - else: - d_ini, d_fim = min_d, max_d - - drr = dr.copy() - if d_ini: - drr = drr[drr["Data"] >= d_ini] - if d_fim: - drr = drr[drr["Data"] <= d_fim] - - st.caption(f"Registros no período: **{len(drr)}** | Dias distintos: **{drr['Data'].dropna().nunique()}**") - - st.divider() - st.subheader("Evolução diária (contagem de registros)") - - evol = ( - drr.dropna(subset=["Data"]) - .groupby("Data") - .size() - .reset_index(name="Registros") - .sort_values("Data") - ) - cmeta1, cmeta2 = st.columns(2) - meta_diaria_evol = cmeta1.number_input("Meta diária (Evolução diária)", min_value=0.0, value=0.0, step=1.0) - meta_mensal_evol = cmeta2.number_input("Meta mensal (Evolução diária)", min_value=0.0, value=0.0, step=1.0) - - if ALT_AVAILABLE and not evol.empty: - base = alt.Chart(evol).encode(x=alt.X("Data:T", title="Data"), y=alt.Y("Registros:Q")) - linha = base.mark_line(color="#1f77b4", point=True) - layers = [linha] - if meta_diaria_evol and meta_diaria_evol > 0: - rule_df = pd.DataFrame({"y": [meta_diaria_evol]}) - rule = alt.Chart(rule_df).mark_rule(color="red", strokeDash=[6, 4]).encode(y="y:Q") - layers.append(rule) - chart = alt.layer(*layers).properties(height=320).interactive() - st.altair_chart(chart, use_container_width=True) - else: - if not evol.empty: - st.line_chart(evol.set_index("Data")["Registros"], height=320, use_container_width=True) - else: - st.info("Sem dados para o período selecionado.") - - _kpis_metas(total_reg=int(evol["Registros"].sum()), datas=evol["Data"], meta_diaria=meta_diaria_evol, meta_mensal=meta_mensal_evol) - - st.markdown("**Baixar dados — Evolução diária**") - _download_buttons(evol, "evolucao_diaria") - - st.divider() - st.subheader("Top 10 Fornecedores (por quantidade)") - top_forn = ( - drr["Fornecedor"].fillna("N/D") - .value_counts() - .head(10) - .reset_index(name="Registros") - .rename(columns={"index": "Fornecedor"}) - ) - - cmeta3, cmeta4 = st.columns(2) - meta_diaria_forn = cmeta3.number_input("Meta diária (Top Fornecedores)", min_value=0.0, value=0.0, step=1.0) - meta_mensal_forn = cmeta4.number_input("Meta mensal (Top Fornecedores)", min_value=0.0, value=0.0, step=1.0) - - if ALT_AVAILABLE and not top_forn.empty: - base = alt.Chart(top_forn).encode( - x=alt.X("Registros:Q"), - y=alt.Y("Fornecedor:N", sort="-x", title="Fornecedor") - ) - barras = base.mark_bar(color="#1f77b4") - chart = barras.properties(height=320).interactive() - st.altair_chart(chart, use_container_width=True) - else: - if not top_forn.empty: - top_series = top_forn.set_index("Fornecedor")["Registros"] - st.bar_chart(top_series, height=320, use_container_width=True) - else: - st.info("Sem dados para o período selecionado.") - - total_forn = pd.to_numeric(top_forn["Registros"], errors="coerce").fillna(0).sum() - _kpis_metas(total_reg=int(total_forn), datas=drr["Data"], meta_diaria=meta_diaria_forn, meta_mensal=meta_mensal_forn) - - st.markdown("**Baixar dados — Top 10 Fornecedores**") - _download_buttons(top_forn, "top10_fornecedores") - - st.divider() - st.subheader("Registros por Transportadora") - por_transp = ( - drr["Transportadora"].fillna("N/D") - .value_counts() - .reset_index(name="Registros") - .rename(columns={"index": "Transportadora"}) - ) - - cmeta5, cmeta6 = st.columns(2) - meta_diaria_transp = cmeta5.number_input("Meta diária (Transportadora)", min_value=0.0, value=0.0, step=1.0) - meta_mensal_transp = cmeta6.number_input("Meta mensal (Transportadora)", min_value=0.0, value=0.0, step=1.0) - - if ALT_AVAILABLE and not por_transp.empty: - base = alt.Chart(por_transp).encode( - x=alt.X("Registros:Q"), - y=alt.Y("Transportadora:N", sort="-x", title="Transportadora") - ) - barras = base.mark_bar(color="#ff7f0e") - chart = barras.properties(height=320).interactive() - st.altair_chart(chart, use_container_width=True) - else: - if not por_transp.empty: - st.bar_chart(por_transp.set_index("Transportadora")["Registros"], height=320, use_container_width=True) - else: - st.info("Sem dados para o período selecionado.") - - total_transp = pd.to_numeric(por_transp["Registros"], errors="coerce").fillna(0).sum() - _kpis_metas(total_reg=int(total_transp), datas=drr["Data"], meta_diaria=meta_diaria_transp, meta_mensal=meta_mensal_transp) - - st.markdown("**Baixar dados — Registros por Transportadora**") - _download_buttons(por_transp, "registros_por_transportadora") - - st.divider() - st.subheader("Status de Aprovação") - aprov = ( - drr["Aprovado"].fillna("N/A") - .value_counts() - .reset_index(name="Registros") - .rename(columns={"index": "Status"}) - ) - - cmeta7, cmeta8 = st.columns(2) - meta_diaria_aprov = cmeta7.number_input("Meta diária (Aprovação)", min_value=0.0, value=0.0, step=1.0) - meta_mensal_aprov = cmeta8.number_input("Meta mensal (Aprovação)", min_value=0.0, value=0.0, step=1.0) - - if ALT_AVAILABLE and not aprov.empty: - base = alt.Chart(aprov).encode( - x=alt.X("Registros:Q"), - y=alt.Y("Status:N", sort="-x", title="Status de aprovação") - ) - barras = base.mark_bar(color="#2ca02c") - chart = barras.properties(height=300).interactive() - st.altair_chart(chart, use_container_width=True) - else: - if not aprov.empty: - st.bar_chart(aprov.set_index("Status")["Registros"], height=300, use_container_width=True) - else: - st.info("Sem dados para o período selecionado.") - - total_aprov = pd.to_numeric(aprov["Registros"], errors="coerce").fillna(0).sum() - _kpis_metas(total_reg=int(total_aprov), datas=drr["Data"], meta_diaria=meta_diaria_aprov, meta_mensal=meta_mensal_aprov) - - st.markdown("**Baixar dados — Status de Aprovação**") - _download_buttons(aprov, "status_aprovacao") - - st.divider() - st.subheader("Distribuição por Tipo de Operação") - tipo_op_df = ( - drr["Tipo de Operação"].fillna("N/D") - .value_counts() - .reset_index(name="Registros") - .rename(columns={"index": "Tipo de Operação"}) - ) - - cmeta9, cmeta10 = st.columns(2) - meta_diaria_tipo = cmeta9.number_input("Meta diária (Tipo de Operação)", min_value=0.0, value=0.0, step=1.0, key="rel__meta_diaria_tipo") - meta_mensal_tipo = cmeta10.number_input("Meta mensal (Tipo de Operação)", min_value=0.0, value=0.0, step=1.0, key="rel__meta_mensal_tipo") - - if ALT_AVAILABLE and not tipo_op_df.empty: - base = alt.Chart(tipo_op_df).encode( - x=alt.X("Registros:Q"), - y=alt.Y("Tipo de Operação:N", sort="-x", title="Tipo de Operação") - ) - barras = base.mark_bar(color="#9467bd") - chart = barras.properties(height=320).interactive() - st.altair_chart(chart, use_container_width=True) - else: - if not tipo_op_df.empty: - st.bar_chart(tipo_op_df.set_index("Tipo de Operação")["Registros"], height=320, use_container_width=True) - else: - st.info("Sem dados para o período selecionado.") - - total_tipo = pd.to_numeric(tipo_op_df["Registros"], errors="coerce").fillna(0).sum() - _kpis_metas(total_reg=int(total_tipo), datas=drr["Data"], meta_diaria=meta_diaria_tipo, meta_mensal=meta_mensal_tipo) - - st.markdown("**Baixar dados — Tipo de Operação**") - _download_buttons(tipo_op_df, "registros_por_tipo_de_operacao") - - # ------------------ ADMIN ------------------ - with aba_admin: - st.header("Área Administrativa") - st.caption("Apenas administradores podem resetar o banco. Requer login + PIN válido.") - - is_admin = admin_login_area() - - if is_admin: - st.divider() - st.subheader("PIN de Segurança") - st.caption("Você precisa de um PIN ativo para autorizar ações destrutivas.") - admin_pin_area(key_prefix="__pin_admin_tab__") - pronto = validar_pin(key_prefix="__pin_val_admin_tab__") - else: - pronto = False - - st.divider() - st.subheader("🧨 Reset da Tabela de Recebimentos") - st.warning( - "Esta ação **apagará TODOS os registros** de **RecebimentoRegistro** de forma **irreversível**.\n\n" - "Recomendo **baixar um backup** antes de continuar." - ) - - df_backup = _fetch_all_recebimentos_df() - total_reg = len(df_backup) - st.info(f"Registros encontrados: **{total_reg}**") - if total_reg > 0: - col_b1, col_b2 = st.columns(2) - csv_bytes = df_backup.to_csv(index=False).encode("utf-8-sig") - col_b1.download_button("⬇️ Baixar CSV (backup)", data=csv_bytes, file_name="backup_recebimento.csv", key="__dl_bkp_csv__") - - xlsx_bytes = _df_to_excel_bytes(df_backup, sheet_name="Backup") - if xlsx_bytes: - col_b2.download_button("⬇️ Baixar Excel (backup)", data=xlsx_bytes, file_name="backup_recebimento.xlsx", key="__dl_bkp_xlsx__") - else: - col_b2.caption("Excel indisponível (openpyxl ausente).") - - st.divider() - st.subheader("Confirmar e Executar Reset") - if not is_admin: - st.error("Você precisa efetuar login de administrador.") - elif not pronto: - st.error("PIN inválido ou ausente. Valide um PIN ativo para prosseguir.") - else: - st.caption("Para confirmar, digite **RESETAR** abaixo:") - confirm_str = st.text_input("Confirmação", placeholder="Digite RESETAR", key="__confirm_reset__") - really = (confirm_str.strip().upper() == "RESETAR") - - do_reset = st.button("🧨 Apagar TODOS os registros agora", type="primary", disabled=not really, key="__btn_reset_all__") - - if do_reset and really: - status = st.status("Executando reset...", expanded=False, state="running") - try: - apagados, err = reset_recebimento_registros() - if err: - status.update(label="❌ Falha no reset", state="error") - st.error(f"Erro ao resetar: {err}") - else: - try: - st.cache_data.clear() - except Exception: - pass - for k in ["__df_preview__", "__payloads_ready__", "__existentes__", "__idx_dups__", "__idx_iguais_db__"]: - st.session_state.pop(k, None) - - status.update(label=f"✅ Reset concluído. Registros apagados: {apagados}", state="complete") - st.success(f"Reset concluído. Registros apagados: {apagados}") - except Exception as e: - status.update(label="❌ Falha no reset", state="error") - st.exception(e) - - -if __name__ == "__main__": +# -*- coding: utf-8 -*- +import io +import re +import secrets +import hashlib +import unicodedata +from datetime import date, datetime, time, timedelta +from typing import Any, Dict, List, Optional, Tuple, Callable + +import pandas as pd +import streamlit as st + +# Altair (para gráficos com linhas/metas). Fallback automático se não existir. +try: + import altair as alt + ALT_AVAILABLE = True +except Exception: + ALT_AVAILABLE = False + +from banco import SessionLocal +from models import RecebimentoRegistro + + +# ========================================================== +# Sessão de banco (compatível com seu app) +# ========================================================== +def _get_db_session(): + try: + from db_router import get_session_for_current_db + return get_session_for_current_db() + except Exception: + return SessionLocal() + + +# ========================================================== +# Utilitários de exportação (CSV/Excel) — usar em todo o app +# ========================================================== +def _df_to_excel_bytes(df: pd.DataFrame, sheet_name: str = "Dados") -> bytes: + """ + Converte um DataFrame em bytes Excel (.xlsx) usando openpyxl. + Retorna b"" se o engine não estiver disponível. + """ + bio = io.BytesIO() + try: + with pd.ExcelWriter(bio, engine="openpyxl") as xw: + df.to_excel(xw, index=False, sheet_name=sheet_name) + bio.seek(0) + return bio.getvalue() + except Exception: + # Fallback: retorna vazio para o chamador desabilitar o botão + return b"" + + +def _download_buttons(df: pd.DataFrame, filename_prefix: str): + """ + Renderiza dois botões de download (CSV e Excel) para um DataFrame. + Usa _df_to_excel_bytes e desabilita o botão de Excel se openpyxl indisponível. + """ + c1, c2 = st.columns(2) + csv_bytes = df.to_csv(index=False).encode("utf-8-sig") + c1.download_button("⬇️ Baixar CSV", data=csv_bytes, file_name=f"{filename_prefix}.csv") + + xlsx = _df_to_excel_bytes(df) + if xlsx: + c2.download_button("⬇️ Baixar Excel", data=xlsx, file_name=f"{filename_prefix}.xlsx") + else: + c2.caption("Excel indisponível (openpyxl ausente).") + + +# ========================================================== +# Conversores padronizados +# ========================================================== +def conv_bool(v): + if v is None: + return None + s = str(v).strip().upper() + if s in ("SIM", "S", "TRUE", "1", "YES", "Y"): + return True + if s in ("NÃO", "NAO", "N", "FALSE", "0", "NO"): + return False + return None # N/A ou vazio + + +def conv_date(v): + if v is None or v == "": + return None + if isinstance(v, date) and not isinstance(v, datetime): + return v + try: + return pd.to_datetime(v, dayfirst=True, errors="coerce").date() + except Exception: + return None + + +def _normalize_hms_str(s: str) -> str: + """ + Normaliza strings flexíveis para HH:MM:SS. + """ + s0 = str(s).strip() + if not s0: + return "" + + # troca separadores incomuns por ':' + s1 = re.sub(r"[hH]", ":", s0) + s1 = re.sub(r"[mM]", ":", s1) + s1 = re.sub(r"[sS]", ":", s1) + s1 = re.sub(r"[.;,\s]+", ":", s1) + + if re.fullmatch(r"\d{1,6}", s1): + n = s1 + if len(n) <= 2: # "8" -> HH + hh = int(n) + return f"{hh:02d}:00:00" + elif len(n) in (3, 4): # "815" / "0815" -> HHMM + hh = int(n[:-2]); mm = int(n[-2:]) + return f"{hh:02d}:{mm:02d}:00" + elif len(n) in (5, 6): # "81530" / "081530" -> HHMMSS + hh = int(n[:-4]); mm = int(n[-4:-2]); ss = int(n[-2:]) + return f"{hh:02d}:{mm:02d}:{ss:02d}" + + parts = [p for p in s1.split(":") if p != ""] + if 1 <= len(parts) <= 3 and all(re.fullmatch(r"\d{1,2}", p) for p in parts): + hh = int(parts[0]) + mm = int(parts[1]) if len(parts) >= 2 else 0 + ss = int(parts[2]) if len(parts) == 3 else 0 + mm += ss // 60; ss = ss % 60 + hh += mm // 60; mm = mm % 60 + hh = hh % 24 + return f"{hh:02d}:{mm:02d}:{ss:02d}" + + # fallback pandas + try: + t = pd.to_datetime(s0, errors="coerce") + if pd.isna(t): + return "" + return t.strftime("%H:%M:%S") + except Exception: + return "" + + +def conv_excel_time(v): + """ + Converte variados formatos para 'HH:MM:SS'. + """ + if v is None or v == "": + return "" + if isinstance(v, str): + return _normalize_hms_str(v) + if isinstance(v, (int, float)): + try: + total_seconds = int(round(float(v) * 86400)) + h = (total_seconds // 3600) % 24 + m = (total_seconds % 3600) // 60 + s = total_seconds % 60 + return f"{h:02d}:{m:02d}:{s:02d}" + except Exception: + return "" + if isinstance(v, time): + return v.strftime("%H:%M:%S") + if isinstance(v, datetime): + return v.strftime("%H:%M:%S") + return "" + + +# ========================================================== +# Modelo oficial: 35 colunas EXATAS (sem ID) +# ========================================================== +OFFICIAL_COLUMNS: List[str] = [ + "HOR. DE CHEGADA NA PORTARIA", + "HOR. DE CHEGADA NO IFS", + "HOR. DE SAÍDA DO IFS/WMS", + "DATA", + "PLACA VEÍCULO", + "TRANSPORTADORA", + "PO", + "INCOTERMS", + "Qtd. SKU", + "NOTA FISCAL", + "FORNECEDOR", + "QUIMICOS", + "FDS", + "NATUREZA DA OPERAÇÃO", + "TIPO DE OPERAÇÃO", + "BARCO", + "IFS", + "WMS", + "ENTREGA", + "PROJETO", + "REPETRO", + "HOR. LIBERAÇÃO PARA OPERAÇÃO", + "HOR. DE CHEGADA NA OPERAÇÃO", + "HOR. DE SAÍDA DA OPERAÇÃO", + "DATA DE EMISSÃO", + "APPROVED?", + "GOOD RECEIPT", + "HORA DE RETORNO DA OPERAÇÃO", + "DIVERGÊNCIA RECEBIMENTO", + "HORA DA LIBERAÇÃO DO MOTORISTA", + "QUALIDADE", + "DIVERGÊNCIA QUALIDADE", + "OBSERVAÇÃO", + "AGENDAMENTO", + "RESPONSÁVEL", +] + +# Colunas de horário para normalização visual +TIME_COLUMNS = [ + "HOR. DE CHEGADA NA PORTARIA", + "HOR. DE CHEGADA NO IFS", + "HOR. DE SAÍDA DO IFS/WMS", + "HOR. LIBERAÇÃO PARA OPERAÇÃO", + "HOR. DE CHEGADA NA OPERAÇÃO", + "HOR. DE SAÍDA DA OPERAÇÃO", + "HORA DE RETORNO DA OPERAÇÃO", + "HORA DA LIBERAÇÃO DO MOTORISTA", +] + +# Colunas opcionais aceitas +OPTIONAL_COLUMNS: List[str] = ["ID", "P.O", "PN", "LOT BATCH"] + +# Mapeamento Excel → campos do modelo +COLUMN_MAP: Dict[str, Tuple[str, Optional[Any]]] = { + "ID": ("id_planilha", lambda v: int(v) if str(v).strip().isdigit() else None), + + "HOR. DE CHEGADA NA PORTARIA": ("hora_chegada_portaria", conv_excel_time), + "HOR. DE CHEGADA NO IFS": ("hora_chegada_ifs", conv_excel_time), + "HOR. DE SAÍDA DO IFS/WMS": ("hora_saida_ifs_wms", conv_excel_time), + "DATA": ("data", conv_date), + "PLACA VEÍCULO": ("placa_veiculo", None), + "TRANSPORTADORA": ("transportadora", None), + "PO": ("po", None), + "INCOTERMS": ("incoterms", None), + "Qtd. SKU": ("qtd_sku", lambda v: int(v) if str(v).strip().isdigit() else None), + "NOTA FISCAL": ("nota_fiscal", None), + "FORNECEDOR": ("fornecedor", None), + "QUIMICOS": ("quimicos", conv_bool), + "FDS": ("fds", conv_bool), + "NATUREZA DA OPERAÇÃO": ("natureza_operacao", None), + "TIPO DE OPERAÇÃO": ("tipo_operacao", None), + "BARCO": ("barco", None), + + "IFS": ("ifs", None), + "WMS": ("wms", None), + + "ENTREGA": ("entrega", None), + "PROJETO": ("projeto", None), + "REPETRO": ("repetro", conv_bool), + "HOR. LIBERAÇÃO PARA OPERAÇÃO": ("hora_liberacao_operacao", conv_excel_time), + "HOR. DE CHEGADA NA OPERAÇÃO": ("hora_chegada_operacao", conv_excel_time), + "HOR. DE SAÍDA DA OPERAÇÃO": ("hora_saida_operacao", conv_excel_time), + "DATA DE EMISSÃO": ("data_emissao", conv_date), + "APPROVED?": ("aprovado", conv_bool), + "GOOD RECEIPT": ("good_receipt", None), + "HORA DE RETORNO DA OPERAÇÃO": ("hora_retorno_operacao", conv_excel_time), + "DIVERGÊNCIA RECEBIMENTO": ("divergencia_recebimento", None), + "HORA DA LIBERAÇÃO DO MOTORISTA": ("hora_liberacao_motorista", conv_excel_time), + "QUALIDADE": ("qualidade", None), + "DIVERGÊNCIA QUALIDADE": ("divergencia_qualidade", None), + "OBSERVAÇÃO": ("observacao", None), + "AGENDAMENTO": ("agendamento", None), + "RESPONSÁVEL": ("responsavel", None), + + "P.O": ("po_alt", None), + "PN": ("pn", None), + "LOT BATCH": ("lot_batch", None), +} + +IGNORED_COLS = {"ID.PO2", "Unnamed: 1", "Unnamed: 40", "Unnamed: 41", "Unnamed: 42"} + + +# ========================================================== +# Funções de banco / ID automático +# ========================================================== +def _get_max_id_planilha() -> int: + db = _get_db_session() + try: + res = db.query(RecebimentoRegistro.id_planilha).order_by(RecebimentoRegistro.id_planilha.desc()).first() + return int(res[0]) if res and res[0] is not None else 0 + finally: + db.close() + + +def _next_id_planilha() -> int: + return _get_max_id_planilha() + 1 + + +def get_ultimo() -> Optional[RecebimentoRegistro]: + db = _get_db_session() + try: + return db.query(RecebimentoRegistro).order_by(RecebimentoRegistro.created_at.desc()).first() + finally: + db.close() + + +def sugestao_defaults() -> Dict[str, Any]: + u = get_ultimo() + if not u: + return {} + return { + "transportadora": u.transportadora, + "incoterms": u.incoterms, + "natureza_operacao": u.natureza_operacao, + "tipo_operacao": u.tipo_operacao, + "barco": u.barco, + "ifs": u.ifs, + "wms": u.wms, + "entrega": u.entrega, + "projeto": u.projeto, + "qualidade": u.qualidade, + "agendamento": u.agendamento, + "responsavel": u.responsavel, + } + + +# ========================================================== +# Helpers de perfil/admin +# ========================================================== +def _is_admin() -> bool: + return (st.session_state.get("perfil") or "").lower() == "admin" + + +# ========================================================== +# PIN (Admin) — com key_prefix para evitar IDs duplicados +# ========================================================== +PIN_KEY = "__PIN_RECEBIMENTO__" + +def _pin_info(): + return st.session_state.get(PIN_KEY, None) + +def _pin_is_valid() -> bool: + info = _pin_info() + if not info: + return False + if datetime.utcnow() >= info["exp"]: + st.session_state.pop("__PIN_OK__", None) + return False + return True + +def admin_pin_area(key_prefix: str = "__pin_admin__"): + """ + Renderiza a área de PIN somente se perfil == 'admin'. + Para não-admin: não renderiza nada. + """ + if not _is_admin(): + return + + with st.expander("🔐 Configurar PIN de edição (somente admin)", expanded=False): + col1, col2, col3 = st.columns([1, 1, 2]) + ttl_min = col1.number_input( + "Validade (min)", + min_value=1, max_value=120, value=15, step=1, + key=f"{key_prefix}__ttl_min__" + ) + if col2.button("Gerar PIN automático", key=f"{key_prefix}__btn_pin__"): + pin = f"{secrets.randbelow(10**6):06d}" + exp = datetime.utcnow() + timedelta(minutes=int(ttl_min)) + st.session_state[PIN_KEY] = {"pin": pin, "exp": exp} + st.session_state.pop("__PIN_OK__", None) + st.success(f"PIN gerado: {pin} (expira em {ttl_min} min)") + info = _pin_info() + if info: + restante = int((info["exp"] - datetime.utcnow()).total_seconds()) + mins = max(0, restante // 60) + secs = max(0, restante % 60) + col3.info(f"PIN atual: **{info['pin']}** | Expira em **{mins:02d}:{secs:02d}** (UTC)") + +def validar_pin(key_prefix: str = "__pin_val__") -> bool: + """ + Para admin: renderiza campo de PIN e valida. + Para não-admin: não renderiza nada e retorna False. + """ + if not _is_admin(): + return False + + if not _pin_is_valid(): + st.error("PIN ausente ou expirado. Solicite ao Admin um novo PIN.") + return False + if not st.session_state.get("__PIN_OK__", False): + entrada = st.text_input("Digite o PIN", type="password", key=f"{key_prefix}__pin_in__") + if st.button("Validar PIN", key=f"{key_prefix}__btn_validar_pin__"): + info = _pin_info() + if info and entrada == info["pin"] and _pin_is_valid(): + st.session_state["__PIN_OK__"] = True + st.success("PIN validado!") + else: + st.error("PIN incorreto ou expirado.") + return st.session_state.get("__PIN_OK__", False) + + +# ========================================================== +# Login de Admin + Reset do banco +# ========================================================== +def admin_login_area() -> bool: + """ + Exibe um formulário de login de admin (usuário/senha) usando st.secrets. + Se as credenciais baterem, seta perfil='admin' e retorna True. + """ + if _is_admin(): + st.success(f"Logado como admin: {st.session_state.get('usuario', 'admin')}") + colA, colB = st.columns([1, 1]) + if colA.button("Sair da sessão admin", key="__btn_logout_admin__"): + st.session_state.pop("perfil", None) + st.session_state.pop("__PIN_OK__", None) + st.rerun() + return True + + admin_user = st.secrets.get("ADMIN_USER", None) + admin_pass = st.secrets.get("ADMIN_PASS", None) + if not admin_user or not admin_pass: + with st.expander("Como configurar o login de admin", expanded=False): + st.info( + "Defina as credenciais em `.streamlit/secrets.toml`:\n\n" + "ADMIN_USER = \"admin\"\n" + "ADMIN_PASS = \"sua_senha_forte\"\n" + ) + + st.subheader("🔐 Login de Administrador") + with st.form("admin_login_form", clear_on_submit=False): + u = st.text_input("Usuário admin", placeholder="ex.: admin", key="__admin_user__") + p = st.text_input("Senha admin", type="password", placeholder="********", key="__admin_pass__") + ok = st.form_submit_button("Entrar", type="primary") + + if ok: + admin_user = st.secrets.get("ADMIN_USER", "") + admin_pass = st.secrets.get("ADMIN_PASS", "") + if u == admin_user and p == admin_pass and u != "" and p != "": + st.session_state["perfil"] = "admin" + st.session_state["usuario"] = u + st.success("Login admin realizado com sucesso.") + st.rerun() + else: + st.error("Credenciais inválidas.") + return False + + return _is_admin() + + +def _get_db(): + return _get_db_session() + + +def _fetch_all_recebimentos_df() -> pd.DataFrame: + """ + Retorna todos os registros de RecebimentoRegistro em DataFrame (snake_case), + útil para backup antes do reset. + """ + db = _get_db() + try: + regs = ( + db.query(RecebimentoRegistro) + .order_by(RecebimentoRegistro.created_at.asc()) + .all() + ) + if not regs: + return pd.DataFrame() + return pd.DataFrame([{c.name: getattr(r, c.name) for c in r.__table__.columns} for r in regs]) + finally: + db.close() + + +def reset_recebimento_registros() -> Tuple[int, Optional[str]]: + """ + Apaga TODOS os registros de RecebimentoRegistro. + Retorna (qtd_apagados, erro_str_ou_None). + """ + db = _get_db() + try: + qtd = db.query(RecebimentoRegistro).count() + db.query(RecebimentoRegistro).delete(synchronize_session=False) + db.commit() + return qtd, None + except Exception as e: + db.rollback() + return 0, str(e) + finally: + db.close() + + +# ========================================================== +# Deduplicação por conteúdo (chave única) +# ========================================================== +UNIQUE_KEY_FIELDS = [ + "data", "nota_fiscal", "fornecedor", "po", "pn", "lot_batch", "placa_veiculo" +] + +def _make_unique_key_from_values(vals: Dict[str, Any]) -> Optional[str]: + parts = [] + all_empty = True + for f in UNIQUE_KEY_FIELDS: + v = vals.get(f) + if f == "data": + d = conv_date(v) + sv = d.isoformat() if d else "" + else: + sv = (str(v).strip().upper() if v is not None else "") + if sv != "": + all_empty = False + parts.append(sv) + if all_empty: + return None + return "|".join(parts) + +def _fetch_existing_unique_keys() -> set: + """Busca no banco os campos necessários e monta o set de chaves únicas existentes.""" + db = _get_db() + try: + rows = db.query( + RecebimentoRegistro.data, + RecebimentoRegistro.nota_fiscal, + RecebimentoRegistro.fornecedor, + RecebimentoRegistro.po, + RecebimentoRegistro.pn, + RecebimentoRegistro.lot_batch, + RecebimentoRegistro.placa_veiculo, + ).all() + keys = set() + for r in rows: + vals = { + "data": r[0], + "nota_fiscal": r[1], + "fornecedor": r[2], + "po": r[3], + "pn": r[4], + "lot_batch": r[5], + "placa_veiculo": r[6], + } + k = _make_unique_key_from_values(vals) + if k: + keys.add(k) + return keys + finally: + db.close() + + +# ========================================================== +# Sanitização ciente por campo +# ========================================================== +DATE_FIELDS = {"data", "data_emissao"} +DATETIME_FIELDS = {"created_at", "updated_at"} +TIME_FIELDS = { + "hora_chegada_portaria", + "hora_chegada_ifs", + "hora_saida_ifs_wms", + "hora_liberacao_operacao", + "hora_chegada_operacao", + "hora_saida_operacao", + "hora_retorno_operacao", + "hora_liberacao_motorista", +} + +def _to_date_py(v) -> Optional[date]: + if v is None: + return None + if isinstance(v, date) and not isinstance(v, datetime): + return v + try: + if not isinstance(v, str) and pd.isna(v): + return None + except Exception: + pass + if isinstance(v, datetime): + return v.date() + try: + dt = pd.to_datetime(v, dayfirst=True, errors="coerce") + if pd.isna(dt): + return None + return dt.date() + except Exception: + return None + +def _to_datetime_py(v) -> Optional[datetime]: + if v is None: + return None + if isinstance(v, datetime): + return v + try: + if not isinstance(v, str) and pd.isna(v): + return None + except Exception: + pass + if isinstance(v, date): + return datetime(v.year, v.month, v.day) + try: + dt = pd.to_datetime(v, errors="coerce") + if pd.isna(dt): + return None + return pd.Timestamp(dt).to_pydatetime() + except Exception: + return None + +def _to_time_str(v) -> str: + if v is None: + return "" + if isinstance(v, str): + return conv_excel_time(v) + return conv_excel_time(v) + +def _sanitize_payload_for_db(payload: Dict[str, Any]) -> Dict[str, Any]: + """ + Converte valores conforme o tipo lógico da coluna: + - DATE_FIELDS -> date Python + - DATETIME_FIELDS -> datetime Python + - TIME_FIELDS -> 'HH:MM:SS' string + Também normaliza NaT/NaN -> None e strings vazias -> None (exceto TIME). + """ + if not payload: + return payload + sanitized: Dict[str, Any] = {} + for k, v in payload.items(): + try: + if not isinstance(v, str) and pd.isna(v): + v = None + except Exception: + pass + + if k in DATE_FIELDS: + val = _to_date_py(v) + elif k in DATETIME_FIELDS: + val = _to_datetime_py(v) + elif k in TIME_FIELDS: + s = _to_time_str(v) + val = s if s != "" else None + else: + if isinstance(v, str) and v.strip() == "": + val = None + else: + val = v + + sanitized[k] = val + return sanitized + + +# ========================================================== +# Persistência (salvar/salvar_lote) +# ========================================================== +_MODEL_COLS = set(RecebimentoRegistro.__table__.columns.keys()) + +def _filter_to_model(payload: Dict[str, Any]) -> Dict[str, Any]: + return {k: v for k, v in payload.items() if k in _MODEL_COLS} + + +def salvar(payload: Dict[str, Any], overwrite: bool = False) -> int: + db = _get_db() + try: + payload = _filter_to_model(payload) + payload = _sanitize_payload_for_db(payload) + + existente = None + if payload.get("id_planilha"): + existente = ( + db.query(RecebimentoRegistro) + .filter(RecebimentoRegistro.id_planilha == payload["id_planilha"]) + .first() + ) + + if existente and not overwrite: + raise ValueError(f"ID {payload['id_planilha']} já existe. Confirme a sobrescrita.") + + if existente and overwrite: + for k, v in payload.items(): + setattr(existente, k, v) + existente.updated_by = st.session_state.get("usuario") + db.commit() + db.refresh(existente) + return existente.id + + novo = RecebimentoRegistro(**payload) + novo.created_by = st.session_state.get("usuario") + db.add(novo) + db.commit() + db.refresh(novo) + return novo.id + except Exception: + db.rollback() + raise + finally: + db.close() + + +def salvar_lote( + payloads: List[Dict[str, Any]], + overwrite_ids: Optional[set] = None, + progress_cb: Optional[Callable[[int, int, int, int], None]] = None +) -> Tuple[int, int, List[str]]: + """ + Salva/atualiza registros em lote, usando uma única sessão e um único commit. + overwrite_ids: ids (id_planilha) que devem ser sobrescritos caso existam. + progress_cb: função chamada a cada item processado: progress_cb(processados, ok, fail, total) + Retorna: (ok, fail, erros) + """ + overwrite_ids = overwrite_ids or set() + ok = fail = 0 + erros: List[str] = [] + total = len(payloads) + + db = _get_db() + try: + for i, p in enumerate(payloads, start=1): + try: + p2 = _filter_to_model(p) + p2 = _sanitize_payload_for_db(p2) + + idp = p2.get("id_planilha") + + if idp and idp in overwrite_ids: + existente = db.query(RecebimentoRegistro).filter(RecebimentoRegistro.id_planilha == idp).first() + if existente: + for k, v in p2.items(): + setattr(existente, k, v) + existente.updated_by = st.session_state.get("usuario") + else: + novo = RecebimentoRegistro(**p2) + novo.created_by = st.session_state.get("usuario") + db.add(novo) + else: + novo = RecebimentoRegistro(**p2) + novo.created_by = st.session_state.get("usuario") + db.add(novo) + + ok += 1 + except Exception as e: + fail += 1 + erros.append(str(e)) + + if progress_cb: + try: + progress_cb(i, ok, fail, total) + except Exception: + pass + + db.commit() + return ok, fail, erros + + except Exception: + db.rollback() + raise + finally: + db.close() + + +# ========================================================== +# Validação do cabeçalho (ordem livre) +# ========================================================== +def _norm(s: str) -> str: + s = unicodedata.normalize("NFKD", str(s)).encode("ASCII", "ignore").decode("ASCII") + s = " ".join(s.split()) + return s.strip().lower() + +# Aliases/sinônimos aceitos -> nome canônico oficial +ALIASES_NORM = { + _norm("P.O"): "PO", + _norm("QTD SKU"): "Qtd. SKU", + _norm("APROVADO"): "APPROVED?", + _norm("HORA LIBERACAO PARA OPERACAO"): "HOR. LIBERAÇÃO PARA OPERAÇÃO", + _norm("HORA CHEGADA NA OPERACAO"): "HOR. DE CHEGADA NA OPERAÇÃO", + _norm("HORA SAIDA DA OPERACAO"): "HOR. DE SAÍDA DA OPERAÇÃO", + _norm("HORA RETORNO DA OPERACAO"): "HORA DE RETORNO DA OPERAÇÃO", + _norm("HORA LIBERACAO DO MOTORISTA"): "HORA DA LIBERAÇÃO DO MOTORISTA", +} + +def validar_e_reordenar(df: pd.DataFrame) -> Tuple[pd.DataFrame, List[str]]: + df = df[[c for c in df.columns if c not in IGNORED_COLS]].copy() + + official_norm_map = {_norm(col): col for col in OFFICIAL_COLUMNS} + optional_norm_map = {_norm(col): col for col in OPTIONAL_COLUMNS} + + found_map: Dict[str, str] = {} + usados_df = set() + + def resolve_official_name(norm_name: str) -> Optional[str]: + norm_name = ALIASES_NORM.get(norm_name, norm_name) + if norm_name in official_norm_map: + return official_norm_map[norm_name] + return None + + for col_df in df.columns: + norm = _norm(col_df) + oficial = resolve_official_name(norm) + if not oficial and norm in optional_norm_map: + oficial = optional_norm_map[norm] + if oficial and (col_df not in usados_df) and (oficial not in found_map): + found_map[oficial] = col_df + usados_df.add(col_df) + + faltantes = [col for col in OFFICIAL_COLUMNS if col not in found_map] + if faltantes: + return df, faltantes + + final_cols_df = [] + if "ID" in found_map: + final_cols_df.append(found_map["ID"]) + + ordered_df_cols = [found_map[col] for col in OFFICIAL_COLUMNS] + final_cols_df.extend(ordered_df_cols) + + extras = [c for c in df.columns if c not in set(final_cols_df)] + final_cols_df.extend(extras) + + df_ok = df[final_cols_df].copy() + + rename_map = {found_map[col]: col for col in OFFICIAL_COLUMNS if col in found_map} + if "ID" in found_map: + rename_map[found_map["ID"]] = "ID" + df_ok.rename(columns=rename_map, inplace=True) + + return df_ok, [] + + +# ========================================================== +# Excel: leitura (cache por conteúdo) +# ========================================================== +def gerar_modelo_oficial_xlsx() -> bytes: + exemplo = pd.DataFrame(columns=OFFICIAL_COLUMNS) + bio = io.BytesIO() + try: + with pd.ExcelWriter(bio, engine="openpyxl") as xw: + exemplo.to_excel(xw, index=False, sheet_name="Recebimento") + bio.seek(0) + return bio.read() + except Exception: + return b"" + +@st.cache_data(show_spinner=False) +def _read_file_cached(file_bytes: bytes, filename_lower: str) -> pd.DataFrame: + bio = io.BytesIO(file_bytes) + if filename_lower.endswith(".csv"): + return pd.read_csv(bio, sep=None, engine="python") + return pd.read_excel(bio, engine="openpyxl") + + +# ========================================================== +# Linha → payload e duplicidades (NaT-safe) +# ========================================================== +def linha_para_payload(row: pd.Series) -> Dict[str, Any]: + payload: Dict[str, Any] = {} + + def _set(campo: str, valor: Any): + if (not isinstance(valor, str) and pd.isna(valor)) or (isinstance(valor, str) and valor.strip() == ""): + return + if isinstance(valor, time): + valor = valor.strftime("%H:%M:%S") + elif isinstance(valor, datetime): + valor = valor.strftime("%Y-%m-%d %H:%M:%S") + elif isinstance(valor, date) and not isinstance(valor, datetime): + valor = valor.isoformat() + if valor is None or (isinstance(valor, str) and valor.strip() == ""): + return + payload[campo] = valor + + for col_excel, (campo, conv) in COLUMN_MAP.items(): + raw = row.get(col_excel, None) + if conv: + try: + val = conv(raw) + except Exception: + val = None + else: + if raw in ("", "NaN") or (isinstance(raw, float) and pd.isna(raw)): + val = None + else: + val = raw + _set(campo, val) + + return payload + +_CONTENT_FIELDS = [m[0] for k, m in COLUMN_MAP.items() if m[0] != "id_planilha"] + +def _hash_content(payload: Dict[str, Any]) -> str: + key = tuple((k, payload.get(k)) for k in sorted(_CONTENT_FIELDS)) + return hashlib.sha256(repr(key).encode("utf-8")).hexdigest() + +def _duplicados_no_arquivo(payloads: List[Dict[str, Any]]) -> List[int]: + seen = {} + dups = [] + for idx, p in enumerate(payloads): + h = _hash_content(p) + if h in seen: + dups.append(idx) + else: + seen[h] = idx + return dups + +def _iguais_no_banco(payloads: List[Dict[str, Any]]) -> List[int]: + """(Compat; não usado na decisão final)""" + iguais = [] + db = _get_db() + try: + for idx, p in enumerate(payloads): + idp = p.get("id_planilha") + if not idp: + continue + reg = db.query(RecebimentoRegistro).filter(RecebimentoRegistro.id_planilha == idp).first() + if not reg: + continue + campos = [c for c in _CONTENT_FIELDS if hasattr(reg, c)] + equal = all(getattr(reg, campo) == p.get(campo) for campo in campos) + if equal: + iguais.append(idx) + return iguais + finally: + db.close() + +def _atribuir_ids_automaticos(payloads: List[Dict[str, Any]]): + atual_max = _get_max_id_planilha() + usados_arquivo = {p.get("id_planilha") for p in payloads if p.get("id_planilha")} + usados_arquivo.discard(None) + db = _get_db() + try: + usados_db = {r[0] for r in db.query(RecebimentoRegistro.id_planilha) + .filter(RecebimentoRegistro.id_planilha.isnot(None)).all()} + finally: + db.close() + proximo = max(atual_max, *(list(usados_db) or [0])) + 1 + for p in payloads: + if not p.get("id_planilha"): + while proximo in usados_arquivo or proximo in usados_db: + proximo += 1 + p["id_planilha"] = proximo + usados_arquivo.add(proximo) + proximo += 1 + +def _normalize_times_in_df(df: pd.DataFrame) -> pd.DataFrame: + df2 = df.copy() + for col in TIME_COLUMNS: + if col in df2.columns: + df2[col] = df2[col].apply(conv_excel_time) + return df2 + + +# ========================================================== +# Utilitários KPI (relatórios) +# ========================================================== +def _months_in_period(dates: pd.Series) -> int: + if dates.empty: + return 0 + dts = pd.to_datetime(dates, errors="coerce") + dts = dts.dropna() + if dts.empty: + return 0 + return dts.dt.to_period("M").nunique() + +def _daily_count(df_dates: pd.Series) -> int: + dts = pd.to_datetime(df_dates, errors="coerce").dropna() + return dts.nunique() + +def _kpis_metas(total_reg: int, datas: pd.Series, meta_diaria: float, meta_mensal: float): + qtd_dias = _daily_count(datas) + qtd_meses = _months_in_period(datas) + media_dia = (total_reg / qtd_dias) if qtd_dias else 0.0 + alvo_mes_total = (meta_mensal * qtd_meses) if meta_mensal else 0.0 + alvo_dia_total = (meta_diaria * qtd_dias) if meta_diaria else 0.0 + + c1, c2, c3, c4 = st.columns(4) + c1.metric("Total no período", total_reg) + c2.metric("Dias (distintos)", qtd_dias) + c3.metric("Média/dia", f"{media_dia:,.2f}".replace(",", "X").replace(".", ",").replace("X", ".")) + if meta_diaria: + perc_dia = (media_dia / meta_diaria * 100.0) if meta_diaria else 0 + c4.metric("Ating. meta diária", f"{perc_dia:.1f}%") + else: + c4.metric("Ating. meta diária", "—") + + c5, c6 = st.columns(2) + if meta_mensal: + ating_mes = (total_reg / alvo_mes_total * 100.0) if alvo_mes_total else 0 + c5.metric("Ating. meta mensal (aj. #meses)", f"{ating_mes:.1f}%") + else: + c5.metric("Ating. meta mensal", "—") + if meta_diaria: + ating_dia_tot = (total_reg / alvo_dia_total * 100.0) if alvo_dia_total else 0 + c6.metric("Ating. total vs soma metas diárias", f"{ating_dia_tot:.1f}%") + else: + c6.metric("Ating. total vs soma metas diárias", "—") + + +# ========================================================== +# Helpers de UI (preview + processamento) +# ========================================================== +def _safe_preview(df: pd.DataFrame, title: str = "Pré-visualização", rows: int = 20): + st.write(f"{title} (até {rows} linhas):") + st.dataframe(df.head(rows), use_container_width=True) + + +def _processar_arquivo( + file_bytes: bytes, + filename_lower: str, + auto_id_missing: bool +) -> Tuple[Optional[pd.DataFrame], Optional[List[Dict[str, Any]]], List[int], List[int], List[int]]: + """ + Pipeline: + 1) leitura + 2) validação/reordenação + 3) normalização de horas (preview) + 4) conversão linha->payload + 5) id automático (opcional) + 6) checagens: duplicados em arquivo, iguais no banco, ids existentes + """ + status = st.status("Iniciando processamento do arquivo...", expanded=True) + try: + status.update(label="📥 Lendo arquivo...", state="running") + df_raw = _read_file_cached(file_bytes, filename_lower) + _safe_preview(df_raw, title="Prévia do arquivo carregado") + + status.update(label="✅ Validando cabeçalho e reordenando...", state="running") + df_valid, faltantes = validar_e_reordenar(df_raw) + if faltantes: + status.update(label="❌ Validação falhou", state="error") + st.error("Importação bloqueada. Coluna(s) obrigatória(s) ausente(s):") + for f in faltantes: + st.markdown(f"- **{f}**") + st.info("Corrija a planilha para prosseguir. Dica: baixe o *Modelo oficial* acima.") + return None, None, [], [], [] + + status.update(label="⏱️ Normalizando colunas de horário para HH:MM:SS...", state="running") + df_preview = _normalize_times_in_df(df_valid) + + status.update(label="🔄 Convertendo linhas para payload...", state="running") + total = len(df_valid) + payloads: List[Dict[str, Any]] = [] + if total > 0: + pbar = st.progress(0, text="Convertendo...") + for i, (_, row) in enumerate(df_valid.iterrows()): + payloads.append(linha_para_payload(row)) + if ((i + 1) % max(1, total // 50) == 0) or (i + 1 == total): + pbar.progress(int(((i + 1) / total) * 100), text=f"Convertendo... {i+1}/{total}") + pbar.empty() + + if auto_id_missing and payloads: + status.update(label="🔢 Atribuindo IDs automáticos (linhas sem ID)...", state="running") + _atribuir_ids_automaticos(payloads) + + status.update(label="🧭 Verificando duplicidades/iguais/IDs existentes...", state="running") + idx_dups_arquivo = _duplicados_no_arquivo(payloads) + idx_iguais_db = _iguais_no_banco(payloads) + + ids_plan = [p.get("id_planilha") for p in payloads if p.get("id_planilha")] + db = _get_db() + try: + existentes = [] + if ids_plan: + existentes = [x[0] for x in db.query(RecebimentoRegistro.id_planilha) + .filter(RecebimentoRegistro.id_planilha.in_(ids_plan)).all()] + finally: + db.close() + + status.update(label="✅ Processamento concluído", state="complete") + return df_preview, payloads, idx_dups_arquivo, idx_iguais_db, existentes + + except Exception as e: + status.update(label="❌ Ocorreu um erro durante o processamento", state="error") + st.exception(e) + return None, None, [], [], [] + + +# ========================================================== +# Formulário manual +# ========================================================== +def formulario(payload: Optional[Dict[str, Any]] = None, key_prefix: str = "new") -> Dict[str, Any]: + sug = sugestao_defaults() + if payload is None: + payload = {} + + id_planilha_auto = payload.get("id_planilha") or _next_id_planilha() + + st.markdown("### 🧾 Cabeçalho") + c1, c2, c3, c4, c5 = st.columns(5) + c1.text_input("ID (planilha) — automático", value=str(id_planilha_auto), disabled=True, key=f"{key_prefix}__id_planilha_view") + data = c2.date_input("DATA", value=payload.get("data") or date.today(), key=f"{key_prefix}__data") + data_emissao = c3.date_input("DATA DE EMISSÃO", value=payload.get("data_emissao") or date.today(), key=f"{key_prefix}__data_emissao") + nf = c4.text_input("NOTA FISCAL", value=payload.get("nota_fiscal") or "", key=f"{key_prefix}__nota_fiscal") + fornecedor = c5.text_input("FORNECEDOR", value=payload.get("fornecedor") or "", key=f"{key_prefix}__fornecedor") + + c6, c7, c8, c9, c10 = st.columns(5) + placa = c6.text_input("PLACA VEÍCULO", value=payload.get("placa_veiculo") or "", key=f"{key_prefix}__placa_veiculo") + trans = c7.text_input("TRANSPORTADORA", value=payload.get("transportadora") or sug.get("transportadora") or "", key=f"{key_prefix}__transportadora") + po = c8.text_input("PO", value=payload.get("po") or "", key=f"{key_prefix}__po") + incot = c9.text_input("INCOTERMS", value=payload.get("incoterms") or sug.get("incoterms") or "", key=f"{key_prefix}__incoterms") + qtd = c10.number_input("Qtd. SKU", min_value=0, value=int(payload.get("qtd_sku") or 0), key=f"{key_prefix}__qtd_sku") + + st.markdown("### ⏱️ Horários (HH:MM:SS)") + c11, c12, c13, c14, c15 = st.columns(5) + hc_port = c11.text_input("HOR. DE CHEGADA NA PORTARIA", value=conv_excel_time(payload.get("hora_chegada_portaria")), key=f"{key_prefix}__hora_chegada_portaria") + hc_ifs = c12.text_input("HOR. DE CHEGADA NO IFS", value=conv_excel_time(payload.get("hora_chegada_ifs")), key=f"{key_prefix}__hora_chegada_ifs") + hs_ifs = c13.text_input("HOR. DE SAÍDA DO IFS/WMS", value=conv_excel_time(payload.get("hora_saida_ifs_wms")), key=f"{key_prefix}__hora_saida_ifs_wms") + hlib = c14.text_input("HOR. LIBERAÇÃO PARA OPERAÇÃO", value=conv_excel_time(payload.get("hora_liberacao_operacao")), key=f"{key_prefix}__hora_liberacao_operacao") + hch_op = c15.text_input("HOR. DE CHEGADA NA OPERAÇÃO", value=conv_excel_time(payload.get("hora_chegada_operacao")), key=f"{key_prefix}__hora_chegada_operacao") + + c16, c17, c18, c19, c20 = st.columns(5) + hs_op = c16.text_input("HOR. DE SAÍDA DA OPERAÇÃO", value=conv_excel_time(payload.get("hora_saida_operacao")), key=f"{key_prefix}__hora_saida_operacao") + hret = c17.text_input("HORA DE RETORNO DA OPERAÇÃO", value=conv_excel_time(payload.get("hora_retorno_operacao")), key=f"{key_prefix}__hora_retorno_operacao") + hmot = c18.text_input("HORA DA LIBERAÇÃO DO MOTORISTA", value=conv_excel_time(payload.get("hora_liberacao_motorista")), key=f"{key_prefix}__hora_liberacao_motorista") + natureza = c19.text_input("NATUREZA DA OPERAÇÃO", value=payload.get("natureza_operacao") or sug.get("natureza_operacao") or "", key=f"{key_prefix}__natureza_operacao") + tipo_op = c20.text_input("TIPO DE OPERAÇÃO", value=payload.get("tipo_operacao") or sug.get("tipo_operacao") or "", key=f"{key_prefix}__tipo_operacao") + + c21, c22, c23, c24, c25 = st.columns(5) + barco = c21.text_input("BARCO", value=payload.get("barco") or sug.get("barco") or "", key=f"{key_prefix}__barco") + ifs = c22.text_input("IFS", value=payload.get("ifs") or sug.get("ifs") or "", key=f"{key_prefix}__ifs") + wms = c23.text_input("WMS", value=payload.get("wms") or sug.get("wms") or "", key=f"{key_prefix}__wms") + entrega = c24.text_input("ENTREGA", value=payload.get("entrega") or sug.get("entrega") or "", key=f"{key_prefix}__entrega") + projeto = c25.text_input("PROJETO", value=payload.get("projeto") or sug.get("projeto") or "", key=f"{key_prefix}__projeto") + + c26, c27, c28, c29, c30 = st.columns(5) + good = c26.text_input("GOOD RECEIPT", value=payload.get("good_receipt") or "", key=f"{key_prefix}__good_receipt") + div_rec = c27.text_input("DIVERGÊNCIA RECEBIMENTO", value=payload.get("divergencia_recebimento") or "", key=f"{key_prefix}__divergencia_recebimento") + qual = c28.text_input("QUALIDADE", value=payload.get("qualidade") or sug.get("qualidade") or "", key=f"{key_prefix}__qualidade") + div_qual = c29.text_input("DIVERGÊNCIA QUALIDADE", value=payload.get("divergencia_qualidade") or "", key=f"{key_prefix}__divergencia_qualidade") + obs = c30.text_area("OBSERVAÇÃO", value=payload.get("observacao") or "", height=80, key=f"{key_prefix}__observacao") + + c31, c32, c33, c34, c35 = st.columns(5) + agend = c31.text_input("AGENDAMENTO", value=payload.get("agendamento") or sug.get("agendamento") or "", key=f"{key_prefix}__agendamento") + resp = c32.text_input("RESPONSÁVEL", value=payload.get("responsavel") or sug.get("responsavel") or "", key=f"{key_prefix}__responsavel") + po_alt = c33.text_input("P.O (alternativo)", value=payload.get("po_alt") or "", key=f"{key_prefix}__po_alt") + pn = c34.text_input("PN", value=payload.get("pn") or "", key=f"{key_prefix}__pn") + lot_batch = c35.text_input("LOT BATCH", value=payload.get("lot_batch") or "", key=f"{key_prefix}__lot_batch") + + st.markdown("### ✅ Sinalizações") + c36b, c37b, c38b, c39b = st.columns(4) + quimicos = c36b.selectbox( + "QUIMICOS", ["SIM", "NÃO", "N/A"], + index=["SIM","NÃO","N/A"].index("SIM" if payload.get("quimicos") is True else "NÃO" if payload.get("quimicos") is False else "N/A"), + key=f"{key_prefix}__quimicos") + fds = c37b.selectbox( + "FDS", ["SIM", "NÃO", "N/A"], + index=["SIM","NÃO","N/A"].index("SIM" if payload.get("fds") is True else "NÃO" if payload.get("fds") is False else "N/A"), + key=f"{key_prefix}__fds") + repetro = c38b.selectbox( + "REPETRO", ["SIM", "NÃO", "N/A"], + index=["SIM","NÃO","N/A"].index("SIM" if payload.get("repetro") is True else "NÃO" if payload.get("repetro") is False else "N/A"), + key=f"{key_prefix}__repetro") + aprovado = c39b.selectbox( + "APPROVED?", ["SIM", "NÃO", "N/A"], + index=["SIM","NÃO","N/A"].index("SIM" if payload.get("aprovado") is True else "NÃO" if payload.get("aprovado") is False else "N/A"), + key=f"{key_prefix}__aprovado") + + return { + "id_planilha": (payload.get("id_planilha") or _next_id_planilha()), + + "DATA": None, # apenas para referência visual no form + "data": data, + "data_emissao": data_emissao, + "nota_fiscal": nf, + "fornecedor": fornecedor, + "placa_veiculo": placa, + "transportadora": trans, + "po": po, + "incoterms": incot, + "qtd_sku": qtd, + + "hora_chegada_portaria": hc_port, + "hora_chegada_ifs": hc_ifs, + "hora_saida_ifs_wms": hs_ifs, + "hora_liberacao_operacao": hlib, + "hora_chegada_operacao": hch_op, + "hora_saida_operacao": hs_op, + "hora_retorno_operacao": hret, + "hora_liberacao_motorista": hmot, + + "natureza_operacao": natureza, + "tipo_operacao": tipo_op, + "barco": barco, + + "ifs": ifs, + "wms": wms, + "entrega": entrega, + "projeto": projeto, + "good_receipt": good, + "divergencia_recebimento": div_rec, + "qualidade": qual, + "divergencia_qualidade": div_qual, + "observacao": obs, + "agendamento": agend, + "responsavel": resp, + + # Opcionais adicionais + "po_alt": po_alt, + "pn": pn, + "lot_batch": lot_batch, + + # Flags + "quimicos": conv_bool(quimicos), + "fds": conv_bool(fds), + "repetro": conv_bool(repetro), + "aprovado": conv_bool(aprovado), + } + + +# ========================================================== +# 🚪 Portão Manual do Módulo +# ========================================================== +def recebimento_manual_gate() -> bool: + st.session_state.setdefault("__rec_allow__", False) + st.session_state.setdefault("__upl_file_bytes__", None) + st.session_state.setdefault("__upl_filename__", None) + st.session_state.setdefault("__df_preview__", None) + st.session_state.setdefault("__payloads_ready__", None) + st.session_state.setdefault("__existentes__", []) + st.session_state.setdefault("__idx_dups__", []) + st.session_state.setdefault("__idx_iguais_db__", []) + st.session_state.setdefault("__import_auto_id__", True) + + st.markdown("### 🔒 Modo manual — Recebimento") + st.caption("Este módulo **não executa automaticamente**. Use os botões abaixo para controlar a execução.") + with st.form("rec_gate_form", clear_on_submit=False): + c1, c2, c3 = st.columns([1, 1, 1]) + run_now = c1.form_submit_button("▶️ Executar módulo agora", type="primary", use_container_width=True) + freeze = c2.form_submit_button("🧊 Congelar (parar atualizações)", use_container_width=True) + reset = c3.form_submit_button("🔄 Recarregar & limpar prévias", use_container_width=True) + + if run_now: + st.session_state["__rec_allow__"] = True + + if freeze: + st.session_state["__rec_allow__"] = False + + if reset: + st.session_state["__upl_file_bytes__"] = None + st.session_state["__upl_filename__"] = None + st.session_state["__df_preview__"] = None + st.session_state["__payloads_ready__"] = None + st.session_state["__existentes__"] = [] + st.session_state["__idx_dups__"] = [] + st.session_state["__idx_iguais_db__"] = [] + st.session_state["__rec_allow__"] = False + st.info("Estados limpos. Módulo congelado. Clique em **▶️ Executar módulo agora** para rodar novamente.") + + return bool(st.session_state["__rec_allow__"]) + + +# ========================================================== +# Interface principal +# ========================================================== +def main(): + st.title("📦 Recebimento — Planilha Oficial (Modo Manual)") + + allowed = recebimento_manual_gate() + if not allowed: + st.stop() + + aba_form, aba_import, aba_reg, aba_rel, aba_admin = st.tabs([ + "Formulário Manual", "Importar Excel", "Registros", "Relatórios", "Admin" + ]) + + # ------------------ FORMULÁRIO ------------------ + with aba_form: + st.header("Novo Registro") + if _is_admin(): + admin_pin_area(key_prefix="__pin_top__") + + data_novo = formulario(key_prefix="new") + if st.button("💾 Salvar novo", type="primary", key="__btn_salvar_manual__"): + status_save = st.status("⏳ Ainda processando...", expanded=False, state="running") + try: + novo_id = salvar(data_novo, overwrite=False) + status_save.update(label=f"✅ Registro criado (ID interno: {novo_id}) — ID (planilha): {data_novo['id_planilha']}", state="complete") + st.success(f"Registro criado (ID interno: {novo_id}) — ID (planilha): {data_novo['id_planilha']}") + except Exception as e: + status_save.update(label="❌ Falha ao salvar", state="error") + st.error(str(e)) + + st.divider() + st.header("Editar Registro Existente (por ID da planilha)") + c1, c2 = st.columns([1, 3]) + buscar = c1.number_input("ID da Planilha", min_value=0, key="edit__id_busca") + if c2.button("🔎 Carregar", key="__btn_buscar__"): + db = _get_db() + try: + reg = db.query(RecebimentoRegistro).filter(RecebimentoRegistro.id_planilha == buscar).first() + if not reg: + st.error("ID não encontrado.") + else: + payload = {c.name: getattr(reg, c.name) for c in reg.__table__.columns} + st.session_state["__edicao__"] = payload + st.success("Registro carregado.") + finally: + db.close() + + if st.session_state.get("__edicao__"): + st.subheader("Edição") + if _is_admin(): + pronto = validar_pin(key_prefix="__pin_val_edit__") + payload_edit = formulario(st.session_state["__edicao__"], key_prefix="edit") + if pronto and st.button("💾 Salvar alterações (sobrescrever)", key="__btn_salvar_edicao__"): + status_save = st.status("⏳ Ainda processando...", expanded=False, state="running") + try: + salvar(payload_edit, overwrite=True) + status_save.update(label="✅ Registro atualizado!", state="complete") + st.success("Registro atualizado!") + st.session_state.pop("__edicao__", None) + except Exception as e: + status_save.update(label="❌ Falha ao salvar alterações", state="error") + st.error(str(e)) + elif not pronto: + st.info("Valide um PIN ativo para autorizar a sobrescrita.") + else: + _ = formulario(st.session_state["__edicao__"], key_prefix="edit_readonly") + st.warning("A edição com sobrescrita requer perfil de administrador.") + + # ------------------ IMPORTAÇÃO ------------------ + with aba_import: + st.header("Importação de Arquivo Oficial") + st.caption("Nada é processado automaticamente. Use os formulários e botões abaixo.") + + col_left, col_right = st.columns([2, 1]) + + # FORM 1 — Seleção/Opções + with col_left.form("form_import_select", clear_on_submit=False): + upload = st.file_uploader( + "Selecione .xlsx ou .csv (sem processamento automático)", + type=["xlsx", "csv"], + key="__upl__" + ) + auto_id_missing = st.checkbox( + "Gerar ID automático p/ linhas sem ID", + value=st.session_state.get("__import_auto_id__", True), + key="__ck_autoid_form__" + ) + submit_carregar = st.form_submit_button("📦 Carregar para pré-processo", use_container_width=True) + if submit_carregar: + if upload is None: + st.warning("Selecione um arquivo primeiro.") + else: + st.session_state["__upl_file_bytes__"] = upload.getvalue() + st.session_state["__upl_filename__"] = upload.name + st.session_state["__import_auto_id__"] = bool(auto_id_missing) + st.success(f"Arquivo **{upload.name}** carregado. Agora clique em **⚙️ Processar agora** quando desejar.") + + with col_right: + st.download_button( + "📥 Modelo oficial (.xlsx)", + data=gerar_modelo_oficial_xlsx(), + file_name="modelo_recebimento_oficial.xlsx", + help="Baixe o layout exato esperado pela base (35 colunas; 'ID' é opcional).", + key="__btn_modelo__" + ) + + st.divider() + + # FORM 2 — Processar / Limpar prévia + with st.form("form_import_process", clear_on_submit=False): + b1, b2 = st.columns([1, 1]) + submit_processar = b1.form_submit_button("⚙️ Processar agora") + submit_reprocessar = b2.form_submit_button("🔁 Reprocessar (limpar prévia)") + + if submit_reprocessar: + st.session_state["__df_preview__"] = None + st.session_state["__payloads_ready__"] = None + st.session_state["__existentes__"] = [] + st.session_state["__idx_dups__"] = [] + st.session_state["__idx_iguais_db__"] = [] + st.info("Prévia descartada. Clique em **⚙️ Processar agora** para gerar novamente (usando o arquivo carregado).") + + if submit_processar: + file_bytes = st.session_state.get("__upl_file_bytes__", None) + filename_lower = (st.session_state.get("__upl_filename__", "") or "").lower() + auto_id = st.session_state.get("__import_auto_id__", True) + + if not file_bytes: + st.warning("Nenhum arquivo carregado. Use o formulário acima para carregar um arquivo.") + else: + df_preview, payloads, idx_dups, idx_iguais_db, existentes = _processar_arquivo( + file_bytes=file_bytes, + filename_lower=filename_lower, + auto_id_missing=auto_id + ) + if (df_preview is not None) and (payloads is not None): + st.session_state["__df_preview__"] = df_preview + st.session_state["__payloads_ready__"] = payloads + st.session_state["__existentes__"] = existentes + st.session_state["__idx_dups__"] = idx_dups + st.session_state["__idx_iguais_db__"] = idx_iguais_db + + if st.session_state.get("__df_preview__") is not None: + _safe_preview( + st.session_state["__df_preview__"], + title="Pré-visualização validada (horários normalizados)" + ) + + st.divider() + st.subheader("Salvar no banco (manual)") + + payloads = st.session_state.get("__payloads_ready__") + idx_dups_arquivo = st.session_state.get("__idx_dups__", []) + existentes = st.session_state.get("__existentes__", []) + + if payloads is None: + st.info("⏳ Ainda sem dados prontos para salvar. Use **⚙️ Processar agora** para preparar os dados.") + else: + # --- Cálculo de novos x ignorados com motivos --- + dups_arquivo_set = set(idx_dups_arquivo) + existentes_set = set(existentes) + existing_keys = _fetch_existing_unique_keys() + + payloads_to_save: List[Dict[str, Any]] = [] + ignored_rows: List[Dict[str, Any]] = [] + + skip_by_file = skip_by_id = skip_by_key = 0 + for i, p in enumerate(payloads): + motivo = None + if i in dups_arquivo_set: + motivo = "Duplicado no arquivo" + idp = p.get("id_planilha") + if motivo is None and idp and idp in existentes_set: + motivo = "ID da planilha já existente no banco" + key = _make_unique_key_from_values(p) + if motivo is None and key and key in existing_keys: + motivo = "Conteúdo já importado (chave única)" + + if motivo: + ignored_rows.append({ + "idx": i, + "Motivo": motivo, + "ID": idp, + "Data": p.get("data"), + "NF": p.get("nota_fiscal"), + "Fornecedor": p.get("fornecedor"), + "PO": p.get("po"), + "PN": p.get("pn"), + "LOT BATCH": p.get("lot_batch"), + "Placa": p.get("placa_veiculo"), + "_key": key, + }) + if motivo == "Duplicado no arquivo": + skip_by_file += 1 + elif motivo == "ID da planilha já existente no banco": + skip_by_id += 1 + elif motivo.startswith("Conteúdo já importado"): + skip_by_key += 1 + else: + payloads_to_save.append(p) + + total_original = len(payloads) + total_novos = len(payloads_to_save) + total_skip = total_original - total_novos + + if skip_by_file: + st.warning(f"⚠️ {skip_by_file} linha(s) duplicada(s) no arquivo foram ignoradas.") + if skip_by_id: + st.warning(f"🔁 {skip_by_id} linha(s) ignorada(s) por ID (planilha) já existente no banco.") + if skip_by_key: + st.warning(f"🚫 {skip_by_key} linha(s) ignorada(s) por conteúdo já importado (chave: Data+NF+Fornecedor+PO+PN+LOT BATCH+Placa).") + + st.info(f"📊 Resumo: **{total_novos} novas** serão importadas | **{total_skip}** ignoradas de **{total_original}** total.") + + # ---------- 🔎 Analisar linhas ignoradas (opcional) ---------- + st.divider() + st.subheader("🔎 Analisar linhas ignoradas (opcional)") + + payloads_to_force: List[Dict[str, Any]] = [] + overwrite_ids_force: set = set() + + if not ignored_rows: + st.caption("Sem linhas ignoradas.") + else: + df_ign = pd.DataFrame(ignored_rows) + with st.expander("Ver linhas ignoradas e motivos", expanded=False): + st.dataframe(df_ign.drop(columns=["_key"]), use_container_width=True) + + # Construir opções legíveis para seleção + options = [] + label_map = {} + for r in ignored_rows: + label = f"Linha {r['idx']+1} | {r['Motivo']} | ID={r['ID'] or '—'} | Data={r['Data'] or '—'} | NF={r['NF'] or '—'} | Forn={r['Fornecedor'] or '—'} | PO={r['PO'] or '—'}" + options.append(label) + label_map[label] = r + + sel = st.multiselect("Selecione as linhas ignoradas que deseja incluir mesmo assim:", options, key="__sel_ignored__") + + # Ações para conflitos de ID + actions_id = {} + for label in sel: + r = label_map[label] + if r["ID"] and r["ID"] in existentes_set: + actions_id[r["idx"]] = st.selectbox( + f"Ação p/ Linha {r['idx']+1} (ID {r['ID']}):", + ["Gerar novo ID", "Sobrescrever (admin + PIN)"], + key=f"__ign_act_{r['idx']}" + ) + + # Permitir duplicidade por chave (se houver esse motivo selecionado) + has_key_dup = any(label_map[l]["Motivo"].startswith("Conteúdo já importado") for l in sel) + allow_dup_key = True + if has_key_dup: + allow_dup_key = st.checkbox( + "✅ Permitir inserir mesmo com conteúdo já importado (pode gerar duplicidade)", + value=False, key="__allow_dup_key__" + ) + + # Preparar payloads selecionados + tratar conflitos + for label in sel: + r = label_map[label] + idx = r["idx"] + p = dict(payloads[idx]) # cópia + + # Conteúdo já importado — exige confirmação + if r["Motivo"].startswith("Conteúdo já importado") and not allow_dup_key: + continue # não incluir sem a confirmação + + # ID existente — agir conforme seleção + if r["ID"] and r["ID"] in existentes_set: + act = actions_id.get(idx, "Gerar novo ID") + if act == "Gerar novo ID": + p["id_planilha"] = None # será atribuído automático + else: + overwrite_ids_force.add(r["ID"]) + + payloads_to_force.append(p) + + # ---------- Botões de salvar ---------- + st.divider() + modo_rapido = st.checkbox("⚡ Modo rápido (salvar em lote, único commit)", value=True, key="__ck_modo_rapido__") + + can_save_new = len(payloads_to_save) > 0 + can_save_all = (len(payloads_to_save) + len(payloads_to_force)) > 0 + + csave, csave_all, cclr = st.columns([1, 2, 1]) + submit_save = csave.button("💾 Salvar apenas **novas**", disabled=not can_save_new, type="primary", key="__btn_save_new__") + submit_save_all = csave_all.button("💾 Salvar **TUDO** (novas + selecionadas das ignoradas)", disabled=not can_save_all, type="primary", key="__btn_save_all__") + clear_preview = cclr.button("🧹 Limpar prévia", key="__btn_clear_prev__") + + def _do_save(payloads_ok: List[Dict[str, Any]], overwrite_ids: set): + status_save = st.status("⏳ Ainda processando...", expanded=False, state="running") + contador_ph = st.empty() + pbar_save = st.progress(0, text="Iniciando gravação...") + + def _progress_cb(processados: int, ok: int, fail: int, total: int): + pct = int((processados / total) * 100) if total else 0 + pbar_save.progress(pct, text=f"Gravando... {processados}/{total}") + contador_ph.markdown(f"**Progresso:** {processados}/{total} | **OK:** {ok} | **Falhas:** {fail}") + + try: + ok, fail, erros = (0, 0, []) + if modo_rapido: + ok, fail, erros = salvar_lote(payloads_ok, overwrite_ids=set(overwrite_ids), progress_cb=_progress_cb) + else: + total_local = len(payloads_ok) + for i, p in enumerate(payloads_ok, start=1): + try: + overwrite = (p.get("id_planilha") in overwrite_ids) if p.get("id_planilha") else False + salvar(p, overwrite=overwrite) + ok += 1 + except Exception as e: + fail += 1 + st.write(f"Erro na linha {i}: {e}") + _progress_cb(i, ok, fail, total_local) + + if erros: + with st.expander("Detalhes de erros", expanded=False): + for i, msg in enumerate(erros, 1): + st.write(f"{i:02d}. {msg}") + + status_save.update(label=f"✅ Importação concluída — {ok} OK, {fail} falhas.", state="complete") + st.success(f"Importação concluída — {ok} OK, {fail} falhas.") + except Exception as e: + status_save.update(label="❌ Falha durante a importação", state="error") + st.exception(e) + finally: + pbar_save.empty() + # Limpar estados + st.session_state["__df_preview__"] = None + st.session_state["__payloads_ready__"] = None + st.session_state["__existentes__"] = [] + st.session_state["__idx_dups__"] = [] + st.session_state["__idx_iguais_db__"] = [] + + if submit_save: + # Apenas novas + _do_save(payloads_to_save, overwrite_ids=set()) + + if submit_save_all: + # Preparar conjunto final: novas + selecionadas das ignoradas + payloads_all = list(payloads_to_save) + list(payloads_to_force) + + # Atribuir IDs automáticos para os que ficaram sem ID (apenas no conjunto final) + if any(p.get("id_planilha") in (None, "") for p in payloads_all): + _atribuir_ids_automaticos(payloads_all) + + # Se houver sobrescritas, exigir admin + PIN + overwrite_ids_all = set(overwrite_ids_force) + if overwrite_ids_all: + if not _is_admin(): + st.error("Sobrescrever registros requer perfil de **Administrador**.") + else: + pronto = validar_pin(key_prefix="__pin_val_save_all__") + if not pronto: + st.warning("Valide um PIN ativo para autorizar sobrescritas e tente novamente.") + else: + _do_save(payloads_all, overwrite_ids=overwrite_ids_all) + else: + _do_save(payloads_all, overwrite_ids=set()) + + if clear_preview: + st.session_state["__df_preview__"] = None + st.session_state["__payloads_ready__"] = None + st.session_state["__existentes__"] = [] + st.session_state["__idx_dups__"] = [] + st.session_state["__idx_iguais_db__"] = [] + st.info("Prévia descartada. Clique em **⚙️ Processar agora** para gerar novamente.") + + # ------------------ LISTA ------------------ + with aba_reg: + st.header("Registros (com filtros)") + + db = _get_db() + try: + regs = ( + db.query(RecebimentoRegistro) + .order_by(RecebimentoRegistro.created_at.desc()) + .limit(5000) + .all() + ) + finally: + db.close() + + if not regs: + st.info("Nenhum registro encontrado.") + st.stop() + + df_base = pd.DataFrame([{c.name: getattr(r, c.name) for c in r.__table__.columns} for r in regs]) + + if not df_base.empty and "data" in df_base.columns: + df_base["data"] = pd.to_datetime(df_base["data"], errors="coerce").dt.date + + def rotulo_to_campo(rotulo: str) -> Optional[str]: + info = COLUMN_MAP.get(rotulo) + return info[0] if info else None + + ordem_rotulos = [] + if "ID" in OPTIONAL_COLUMNS: + ordem_rotulos.append("ID") + ordem_rotulos.extend(OFFICIAL_COLUMNS) + + colunas_display = [] + for rot in ordem_rotulos: + campo = rotulo_to_campo(rot) + if campo and campo in df_base.columns: + colunas_display.append((rot, campo)) + + df_disp = pd.DataFrame() + for rot, campo in colunas_display: + df_disp[rot] = df_base[campo] + + usados = {campo for _rot, campo in colunas_display} + extras = [c for c in df_base.columns if c not in usados] + for extra in extras: + df_disp[extra] = df_base[extra] + + with st.expander("🔎 Filtros", expanded=True): + c1, c2, c3 = st.columns(3) + f_po = c1.text_input("P.O (campo: po)", placeholder="ex.: 4500...", key="reg__f_po") + f_pn = c2.text_input("PN (campo: pn)", placeholder="ex.: 1Z23...", key="reg__f_pn") + f_lot = c3.text_input("LOT BATCH (campo: lot_batch)", placeholder="ex.: L123...", key="reg__f_lot") + + c4, c5, c6 = st.columns(3) + f_nf = c4.text_input("Nota Fiscal (campo: nota_fiscal)", placeholder="ex.: 12345", key="reg__f_nf") + f_forn = c5.text_input("Fornecedor (campo: fornecedor)", placeholder="ex.: ACME", key="reg__f_forn") + f_placa = c6.text_input("Placa do Veículo (campo: placa_veiculo)", placeholder="ex.: ABC1D23", key="reg__f_placa") + + c7, c8, c9 = st.columns([1, 1, 1]) + f_data_ini = c7.date_input("Data inicial (campo: data)", value=None, key="reg__f_data_ini") + f_data_fim = c8.date_input("Data final (campo: data)", value=None, key="reg__f_data_fim") + limpar = c9.button("Limpar filtros", key="reg__btn_limpar_filtros") + + if limpar: + for k in ["reg__f_po", "reg__f_pn", "reg__f_lot", "reg__f_nf", "reg__f_forn", "reg__f_placa", "reg__f_data_ini", "reg__f_data_fim"]: + if k in st.session_state: + del st.session_state[k] + st.rerun() + + df_filtrado = df_base.copy() + + def _contains(df, col, term): + if not term or col not in df.columns: + return pd.Series([True] * len(df)) + return df[col].astype(str).str.contains(str(term), case=False, na=False) + + if f_po: df_filtrado = df_filtrado[_contains(df_filtrado, "po", f_po)] + if f_pn: df_filtrado = df_filtrado[_contains(df_filtrado, "pn", f_pn)] + if f_lot: df_filtrado = df_filtrado[_contains(df_filtrado, "lot_batch", f_lot)] + if f_nf: df_filtrado = df_filtrado[_contains(df_filtrado, "nota_fiscal", f_nf)] + if f_forn: df_filtrado = df_filtrado[_contains(df_filtrado, "fornecedor", f_forn)] + if f_placa:df_filtrado = df_filtrado[_contains(df_filtrado, "placa_veiculo", f_placa)] + if "data" in df_filtrado.columns: + if f_data_ini: + df_filtrado = df_filtrado[df_filtrado["data"] >= f_data_ini] + if f_data_fim: + df_filtrado = df_filtrado[df_filtrado["data"] <= f_data_fim] + + df_disp_filtrado = pd.DataFrame() + for rot, campo in colunas_display: + if campo in df_filtrado.columns: + df_disp_filtrado[rot] = df_filtrado[campo] + for extra in extras: + if extra in df_filtrado.columns: + df_disp_filtrado[extra] = df_filtrado[extra] + + total_filtrado = len(df_disp_filtrado) + if "DATA" in df_disp_filtrado.columns: + datas_validas = pd.to_datetime(df_disp_filtrado["DATA"], errors="coerce").dt.date.dropna() + if not datas_validas.empty: + st.caption( + f"Exibindo **{total_filtrado}** registro(s). " + f"Primeira data: **{datas_validas.min()}** — Última data: **{datas_validas.max()}**." + ) + else: + st.caption(f"Exibindo **{total_filtrado}** registro(s).") + else: + st.caption(f"Exibindo **{total_filtrado}** registro(s).") + + st.markdown("**Colunas visíveis**") + final_labels_order = list(df_disp_filtrado.columns) + + vis_key = "__cols_visiveis_labels__" + if vis_key not in st.session_state: + st.session_state[vis_key] = set(final_labels_order) + else: + st.session_state[vis_key] = {lbl for lbl in st.session_state[vis_key] if lbl in final_labels_order} + if not st.session_state[vis_key]: + st.session_state[vis_key] = set(final_labels_order) + + def render_columns_selector(title: str, labels: List[str], state_key: str): + container_supported = hasattr(st, "popover") + ctx_mgr = st.popover(title) if container_supported else st.expander(title, expanded=False) + with ctx_mgr: + st.write("Marque as colunas que deseja **exibir**:") + ac1, ac2 = st.columns(2) + if ac1.button("Selecionar tudo"): + st.session_state[state_key] = set(labels) + if ac2.button("Limpar"): + st.session_state[state_key] = set() + + left, right = st.columns(2) + half = (len(labels) + 1) // 2 + for i, lbl in enumerate(labels): + col = left if i < half else right + checked = lbl in st.session_state[state_key] + new_val = col.checkbox(lbl, value=checked, key=f"__chk_col_{state_key}_{lbl}") + if new_val and lbl not in st.session_state[state_key]: + st.session_state[state_key].add(lbl) + if (not new_val) and (lbl in st.session_state[state_key]): + st.session_state[state_key].discard(lbl) + + render_columns_selector("⚙️ Definir colunas", final_labels_order, vis_key) + + visible_labels_sorted = [lbl for lbl in final_labels_order if lbl in st.session_state[vis_key]] + if not visible_labels_sorted: + st.warning("Nenhuma coluna selecionada. Selecione pelo menos uma para visualizar a tabela.") + else: + st.dataframe(df_disp_filtrado[visible_labels_sorted], use_container_width=True) + + cexp1, cexp2 = st.columns([1, 1]) + csv_data = df_disp_filtrado.to_csv(index=False).encode("utf-8-sig") + cexp1.download_button("⬇️ Exportar CSV (filtrados)", data=csv_data, file_name="registros_filtrados.csv") + xlsx_all = _df_to_excel_bytes(df_disp_filtrado) + if xlsx_all: + cexp2.download_button("⬇️ Exportar Excel (filtrados)", data=xlsx_all, file_name="registros_filtrados.xlsx") + else: + cexp2.caption("Excel indisponível (openpyxl ausente).") + + # ------------------ RELATÓRIOS ------------------ + with aba_rel: + st.header("Relatórios") + + db = _get_db() + try: + regs = ( + db.query(RecebimentoRegistro) + .order_by(RecebimentoRegistro.created_at.desc()) + .limit(20000) + .all() + ) + finally: + db.close() + + if not regs: + st.info("Nenhum registro para relatório.") + st.stop() + + rows = [] + for r in regs: + rows.append({ + "Data": getattr(r, "data", None), + "Fornecedor": getattr(r, "fornecedor", None), + "PO": getattr(r, "po", None), + "PO (alt)": getattr(r, "po_alt", None), + "PN": getattr(r, "pn", None), + "LOT BATCH": getattr(r, "lot_batch", None), + "Placa": getattr(r, "placa_veiculo", None), + "Transportadora": getattr(r, "transportadora", None), + "Aprovado": "SIM" if getattr(r, "aprovado", None) is True else ("NÃO" if getattr(r, "aprovado", None) is False else "N/A"), + "SKU": getattr(r, "qtd_sku", None), + "Tipo de Operação": getattr(r, "tipo_operacao", None), + # Removido campo "Divergência" genérico — não faz parte do layout oficial + }) + dr = pd.DataFrame(rows) + + dts = pd.to_datetime(dr.get("Data"), errors="coerce") + if dts.notna().any(): + min_d = dts.min().date() + max_d = dts.max().date() + else: + min_d = date.today().replace(day=1) + max_d = date.today() + + dr["Data"] = dts.dt.date + + periodo = st.date_input( + "Período (Data inicial e final)", + value=(min_d, max_d), + help="Selecione uma data inicial e final para filtrar todos os gráficos." + ) + if isinstance(periodo, tuple) and len(periodo) == 2: + d_ini, d_fim = periodo + else: + d_ini, d_fim = min_d, max_d + + drr = dr.copy() + if d_ini: + drr = drr[drr["Data"] >= d_ini] + if d_fim: + drr = drr[drr["Data"] <= d_fim] + + st.caption(f"Registros no período: **{len(drr)}** | Dias distintos: **{drr['Data'].dropna().nunique()}**") + + st.divider() + st.subheader("Evolução diária (contagem de registros)") + + evol = ( + drr.dropna(subset=["Data"]) + .groupby("Data") + .size() + .reset_index(name="Registros") + .sort_values("Data") + ) + cmeta1, cmeta2 = st.columns(2) + meta_diaria_evol = cmeta1.number_input("Meta diária (Evolução diária)", min_value=0.0, value=0.0, step=1.0) + meta_mensal_evol = cmeta2.number_input("Meta mensal (Evolução diária)", min_value=0.0, value=0.0, step=1.0) + + if ALT_AVAILABLE and not evol.empty: + base = alt.Chart(evol).encode(x=alt.X("Data:T", title="Data"), y=alt.Y("Registros:Q")) + linha = base.mark_line(color="#1f77b4", point=True) + layers = [linha] + if meta_diaria_evol and meta_diaria_evol > 0: + rule_df = pd.DataFrame({"y": [meta_diaria_evol]}) + rule = alt.Chart(rule_df).mark_rule(color="red", strokeDash=[6, 4]).encode(y="y:Q") + layers.append(rule) + chart = alt.layer(*layers).properties(height=320).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not evol.empty: + st.line_chart(evol.set_index("Data")["Registros"], height=320, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + _kpis_metas(total_reg=int(evol["Registros"].sum()), datas=evol["Data"], meta_diaria=meta_diaria_evol, meta_mensal=meta_mensal_evol) + + st.markdown("**Baixar dados — Evolução diária**") + _download_buttons(evol, "evolucao_diaria") + + st.divider() + st.subheader("Top 10 Fornecedores (por quantidade)") + top_forn = ( + drr["Fornecedor"].fillna("N/D") + .value_counts() + .head(10) + .reset_index(name="Registros") + .rename(columns={"index": "Fornecedor"}) + ) + + cmeta3, cmeta4 = st.columns(2) + meta_diaria_forn = cmeta3.number_input("Meta diária (Top Fornecedores)", min_value=0.0, value=0.0, step=1.0) + meta_mensal_forn = cmeta4.number_input("Meta mensal (Top Fornecedores)", min_value=0.0, value=0.0, step=1.0) + + if ALT_AVAILABLE and not top_forn.empty: + base = alt.Chart(top_forn).encode( + x=alt.X("Registros:Q"), + y=alt.Y("Fornecedor:N", sort="-x", title="Fornecedor") + ) + barras = base.mark_bar(color="#1f77b4") + chart = barras.properties(height=320).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not top_forn.empty: + top_series = top_forn.set_index("Fornecedor")["Registros"] + st.bar_chart(top_series, height=320, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + total_forn = pd.to_numeric(top_forn["Registros"], errors="coerce").fillna(0).sum() + _kpis_metas(total_reg=int(total_forn), datas=drr["Data"], meta_diaria=meta_diaria_forn, meta_mensal=meta_mensal_forn) + + st.markdown("**Baixar dados — Top 10 Fornecedores**") + _download_buttons(top_forn, "top10_fornecedores") + + st.divider() + st.subheader("Registros por Transportadora") + por_transp = ( + drr["Transportadora"].fillna("N/D") + .value_counts() + .reset_index(name="Registros") + .rename(columns={"index": "Transportadora"}) + ) + + cmeta5, cmeta6 = st.columns(2) + meta_diaria_transp = cmeta5.number_input("Meta diária (Transportadora)", min_value=0.0, value=0.0, step=1.0) + meta_mensal_transp = cmeta6.number_input("Meta mensal (Transportadora)", min_value=0.0, value=0.0, step=1.0) + + if ALT_AVAILABLE and not por_transp.empty: + base = alt.Chart(por_transp).encode( + x=alt.X("Registros:Q"), + y=alt.Y("Transportadora:N", sort="-x", title="Transportadora") + ) + barras = base.mark_bar(color="#ff7f0e") + chart = barras.properties(height=320).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not por_transp.empty: + st.bar_chart(por_transp.set_index("Transportadora")["Registros"], height=320, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + total_transp = pd.to_numeric(por_transp["Registros"], errors="coerce").fillna(0).sum() + _kpis_metas(total_reg=int(total_transp), datas=drr["Data"], meta_diaria=meta_diaria_transp, meta_mensal=meta_mensal_transp) + + st.markdown("**Baixar dados — Registros por Transportadora**") + _download_buttons(por_transp, "registros_por_transportadora") + + st.divider() + st.subheader("Status de Aprovação") + aprov = ( + drr["Aprovado"].fillna("N/A") + .value_counts() + .reset_index(name="Registros") + .rename(columns={"index": "Status"}) + ) + + cmeta7, cmeta8 = st.columns(2) + meta_diaria_aprov = cmeta7.number_input("Meta diária (Aprovação)", min_value=0.0, value=0.0, step=1.0) + meta_mensal_aprov = cmeta8.number_input("Meta mensal (Aprovação)", min_value=0.0, value=0.0, step=1.0) + + if ALT_AVAILABLE and not aprov.empty: + base = alt.Chart(aprov).encode( + x=alt.X("Registros:Q"), + y=alt.Y("Status:N", sort="-x", title="Status de aprovação") + ) + barras = base.mark_bar(color="#2ca02c") + chart = barras.properties(height=300).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not aprov.empty: + st.bar_chart(aprov.set_index("Status")["Registros"], height=300, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + total_aprov = pd.to_numeric(aprov["Registros"], errors="coerce").fillna(0).sum() + _kpis_metas(total_reg=int(total_aprov), datas=drr["Data"], meta_diaria=meta_diaria_aprov, meta_mensal=meta_mensal_aprov) + + st.markdown("**Baixar dados — Status de Aprovação**") + _download_buttons(aprov, "status_aprovacao") + + st.divider() + st.subheader("Distribuição por Tipo de Operação") + tipo_op_df = ( + drr["Tipo de Operação"].fillna("N/D") + .value_counts() + .reset_index(name="Registros") + .rename(columns={"index": "Tipo de Operação"}) + ) + + cmeta9, cmeta10 = st.columns(2) + meta_diaria_tipo = cmeta9.number_input("Meta diária (Tipo de Operação)", min_value=0.0, value=0.0, step=1.0, key="rel__meta_diaria_tipo") + meta_mensal_tipo = cmeta10.number_input("Meta mensal (Tipo de Operação)", min_value=0.0, value=0.0, step=1.0, key="rel__meta_mensal_tipo") + + if ALT_AVAILABLE and not tipo_op_df.empty: + base = alt.Chart(tipo_op_df).encode( + x=alt.X("Registros:Q"), + y=alt.Y("Tipo de Operação:N", sort="-x", title="Tipo de Operação") + ) + barras = base.mark_bar(color="#9467bd") + chart = barras.properties(height=320).interactive() + st.altair_chart(chart, use_container_width=True) + else: + if not tipo_op_df.empty: + st.bar_chart(tipo_op_df.set_index("Tipo de Operação")["Registros"], height=320, use_container_width=True) + else: + st.info("Sem dados para o período selecionado.") + + total_tipo = pd.to_numeric(tipo_op_df["Registros"], errors="coerce").fillna(0).sum() + _kpis_metas(total_reg=int(total_tipo), datas=drr["Data"], meta_diaria=meta_diaria_tipo, meta_mensal=meta_mensal_tipo) + + st.markdown("**Baixar dados — Tipo de Operação**") + _download_buttons(tipo_op_df, "registros_por_tipo_de_operacao") + + # ------------------ ADMIN ------------------ + with aba_admin: + st.header("Área Administrativa") + st.caption("Apenas administradores podem resetar o banco. Requer login + PIN válido.") + + is_admin = admin_login_area() + + if is_admin: + st.divider() + st.subheader("PIN de Segurança") + st.caption("Você precisa de um PIN ativo para autorizar ações destrutivas.") + admin_pin_area(key_prefix="__pin_admin_tab__") + pronto = validar_pin(key_prefix="__pin_val_admin_tab__") + else: + pronto = False + + st.divider() + st.subheader("🧨 Reset da Tabela de Recebimentos") + st.warning( + "Esta ação **apagará TODOS os registros** de **RecebimentoRegistro** de forma **irreversível**.\n\n" + "Recomendo **baixar um backup** antes de continuar." + ) + + df_backup = _fetch_all_recebimentos_df() + total_reg = len(df_backup) + st.info(f"Registros encontrados: **{total_reg}**") + if total_reg > 0: + col_b1, col_b2 = st.columns(2) + csv_bytes = df_backup.to_csv(index=False).encode("utf-8-sig") + col_b1.download_button("⬇️ Baixar CSV (backup)", data=csv_bytes, file_name="backup_recebimento.csv", key="__dl_bkp_csv__") + + xlsx_bytes = _df_to_excel_bytes(df_backup, sheet_name="Backup") + if xlsx_bytes: + col_b2.download_button("⬇️ Baixar Excel (backup)", data=xlsx_bytes, file_name="backup_recebimento.xlsx", key="__dl_bkp_xlsx__") + else: + col_b2.caption("Excel indisponível (openpyxl ausente).") + + st.divider() + st.subheader("Confirmar e Executar Reset") + if not is_admin: + st.error("Você precisa efetuar login de administrador.") + elif not pronto: + st.error("PIN inválido ou ausente. Valide um PIN ativo para prosseguir.") + else: + st.caption("Para confirmar, digite **RESETAR** abaixo:") + confirm_str = st.text_input("Confirmação", placeholder="Digite RESETAR", key="__confirm_reset__") + really = (confirm_str.strip().upper() == "RESETAR") + + do_reset = st.button("🧨 Apagar TODOS os registros agora", type="primary", disabled=not really, key="__btn_reset_all__") + + if do_reset and really: + status = st.status("Executando reset...", expanded=False, state="running") + try: + apagados, err = reset_recebimento_registros() + if err: + status.update(label="❌ Falha no reset", state="error") + st.error(f"Erro ao resetar: {err}") + else: + try: + st.cache_data.clear() + except Exception: + pass + for k in ["__df_preview__", "__payloads_ready__", "__existentes__", "__idx_dups__", "__idx_iguais_db__"]: + st.session_state.pop(k, None) + + status.update(label=f"✅ Reset concluído. Registros apagados: {apagados}", state="complete") + st.success(f"Reset concluído. Registros apagados: {apagados}") + except Exception as e: + status.update(label="❌ Falha no reset", state="error") + st.exception(e) + + +if __name__ == "__main__": main() \ No newline at end of file