diff --git "a/rnc.py" "b/rnc.py" --- "a/rnc.py" +++ "b/rnc.py" @@ -1,1275 +1,1276 @@ - -# rnc.py -# -*- coding: utf-8 -*- -""" -Módulo: RNC • Registro de Não Conformidades (FOR-SGQ-08 Rev 01) -Recursos: -- Cadastro de RNC com campos do formulário FOR-SGQ-08 (cabeçalho, descrição) -- KPIs (cards) -- Edição de RNC existente (carrega por código ou lista recente) -- Timeline/comentários com atualização de status/prazo/responsável (regras de permissão) -- Plano de Ação (imediata, corretiva, preventiva) – criar e atualizar status/eficácia -- Anexos (upload incremental) -- Auditoria (utils_auditoria.registrar_log) — opcional -- Notificações por e-mail (opcional) se utils_email ou utils_notificacao existir -""" - -import os -from datetime import datetime, date -from typing import Optional, Dict, List - -import streamlit as st - -from banco import SessionLocal -from models import RNC, RNCComentario, RNCAcaoCorretiva, RNCAnexo - - -# =============================== -# Configurações do módulo -# =============================== - -# Diretório para anexos: pode definir via .env (RNC_UPLOAD_DIR) -UPLOAD_DIR = os.getenv("RNC_UPLOAD_DIR", os.path.join("uploads", "rnc")) - -# Controle de permissões -ALLOW_CREATOR_OR_RESP_TO_UPDATE = True # criador ou responsável podem atualizar campos -ONLY_ADMIN_CAN_CLOSE_OR_CANCEL = True # apenas admin pode encerrar/cancelar - -# Status, severidade etc. -STATUS_OPCOES = [ - "Aberta", "Em Análise", "Plano de Ação", - "Implementada", "Verificação", "Encerrada", "Cancelada" -] -SEVERIDADES = ["Crítica", "Maior", "Menor"] -PRIORIDADES = ["Alta", "Média", "Baixa"] -TIPOS = ["Produto", "Processo", "Sistema", "Documentação", "Outro"] - -ORIGENS_FORMS = ["Auditoria Interna", "Auditoria Externa", "Outras"] - - -# =============================== -# Utilidades auxiliares -# =============================== -def _ensure_upload_dir(path: str): - try: - os.makedirs(path, exist_ok=True) - except Exception: - pass - - -def _registrar_log(usuario: Optional[str], acao: str, tabela: str, registro_id: Optional[int] = None): - try: - from utils_auditoria import registrar_log - registrar_log(usuario=usuario, acao=acao, tabela=tabela, registro_id=registro_id) - except Exception: - # fallback silencioso - pass - - -def _send_email_para_responsavel(assunto: str, corpo: str, destinatarios: List[str]): - """Envia e-mail (opcional). Tenta utils_email ou utils_notificacao. Ignora se indisponível.""" - if not destinatarios: - return - try: - from utils_email import send_email # assinatura esperada: (to, subject, body) - for to in destinatarios: - try: - send_email(to, assunto, corpo) - except Exception: - pass - return - except Exception: - pass - - try: - from utils_notificacao import send_email # alternativa - for to in destinatarios: - try: - send_email(to, assunto, corpo) - except Exception: - pass - return - except Exception: - pass - - -def _get_user_email(login: Optional[str]) -> Optional[str]: - """Tenta obter e-mail do usuário em st.session_state.""" - # Ajuste conforme sua infra. Aqui utiliza e-mail da sessão, se existir. - return st.session_state.get("email") - - -def gerar_codigo_rnc(db) -> str: - """Gera código sequencial anual: RNC-YYYY-XXXX (4 dígitos).""" - ano = datetime.utcnow().year - prefixo = f"RNC-{ano}-" - ultimo = ( - db.query(RNC) - .filter(RNC.codigo.like(f"{prefixo}%")) - .order_by(RNC.codigo.desc()) - .first() - ) - if ultimo and ultimo.codigo and ultimo.codigo.startswith(prefixo): - try: - seq = int(ultimo.codigo.split("-")[-1]) + 1 - except Exception: - seq = 1 - else: - seq = 1 - return f"{prefixo}{seq:04d}" - - -def _is_admin(perfil: str) -> bool: - return (perfil or "").lower() == "admin" - - -def can_edit(rnc: RNC, usuario: Optional[str], perfil: str) -> bool: - """Pode editar campos (status/prazo/responsável)?""" - if _is_admin(perfil): - return True - if not ALLOW_CREATOR_OR_RESP_TO_UPDATE: - return False - if not usuario: - return False - return usuario == (rnc.criado_por or "") or usuario == (rnc.responsavel or "") - - -def can_close_or_cancel(perfil: str) -> bool: - """Pode ENCERRAR ou CANCELAR?""" - if ONLY_ADMIN_CAN_CLOSE_OR_CANCEL: - return _is_admin(perfil) - return True - - -# =============================== -# KPIs -# =============================== -def _kpis_area(db, usuario: str, perfil: str): - total = db.query(RNC).count() - em_andamento = db.query(RNC).filter(~RNC.status.in_(["Encerrada", "Cancelada"])).count() - atrasadas = db.query(RNC).filter( - RNC.prazo != None, - RNC.prazo < datetime.utcnow(), - ~RNC.status.in_(["Encerrada", "Cancelada"]) - ).count() - minhas = db.query(RNC).filter( - (RNC.responsavel == usuario) | (RNC.criado_por == usuario) - ).count() - - col1, col2, col3, col4 = st.columns(4) - col1.metric("Total RNC", total) - col2.metric("Em andamento", em_andamento) - col3.metric("Atrasadas", atrasadas, help="Prazo vencido e não encerradas/canceladas") - col4.metric("Minhas (resp./criadas)", minhas) - - -# =============================== -# Formulário: Nova RNC (FOR-SGQ-08) -# =============================== -def _form_nova_rnc(db): - st.subheader("➕ Nova RNC • FOR-SGQ-08") - - # >>> Controles fora do form para permitir re-render dinâmico das linhas de Ações - qtd_ai = st.number_input( - "Quantidade de ações imediatas", - min_value=1, max_value=15, - value=st.session_state.get("__qtd_ai__", 1), - step=1, - help="Ajuste aqui o número de linhas de ações imediatas (Descrição/Responsável/Data)." - ) - st.session_state["__qtd_ai__"] = int(qtd_ai) - - qtd_ac = st.number_input( - "Quantidade de ações CORRETIVAS", - min_value=0, max_value=25, - value=st.session_state.get("__qtd_ac__", 0), - step=1, - help="Defina o número de linhas para Ações Corretivas (Descrição/Responsável/Data)." - ) - st.session_state["__qtd_ac__"] = int(qtd_ac) - - qtd_ap = st.number_input( - "Quantidade de ações PREVENTIVAS", - min_value=0, max_value=25, - value=st.session_state.get("__qtd_ap__", 0), - step=1, - help="Defina o número de linhas para Ações Preventivas (Descrição/Responsável/Data)." - ) - st.session_state["__qtd_ap__"] = int(qtd_ap) - - with st.form("form_nova_rnc"): - st.markdown("#### Cabeçalho do Formulário") - col_h1, col_h2, col_h3 = st.columns([1, 1, 1]) - data_form = col_h1.date_input("DATA", value=date.today(), format="DD/MM/YYYY") - emitente = col_h2.text_input("EMITENTE") - rnc_cliente_numero = col_h3.text_input("RNC CLIENTE Nº (opcional)", placeholder="N/A") - - col_h4, col_h5 = st.columns([1, 1]) - cliente_emitente = col_h4.text_input("CLIENTE EMITENTE (opcional)", placeholder="N/A") - origem_form = col_h5.selectbox("Origem", ORIGENS_FORMS, index=2) # padrão: Outras - - st.markdown("##### Envolvidos") - col_e1, col_e2, col_e3 = st.columns([2, 1, 1]) - inv1_nome = col_e1.text_input("Envolvido 1 — Nome", placeholder="Ex.: Andreia Araújo") - inv1_matr = col_e2.text_input("Matrícula 1", placeholder="") - inv1_func = col_e3.text_input("Função 1", placeholder="Ex.: Operations Leader") - - col_e4, col_e5, col_e6 = st.columns([2, 1, 1]) - inv2_nome = col_e4.text_input("Envolvido 2 — Nome", placeholder="") - inv2_matr = col_e5.text_input("Matrícula 2", placeholder="") - inv2_func = col_e6.text_input("Função 2", placeholder="") - - col_h6, col_h7 = st.columns([1, 1]) - area_solicitante = col_h6.text_input("Área Solicitante", placeholder="Ex.: Operacional SBM") - area_notificada = col_h7.text_input("Área Notificada", placeholder="Ex.: QSMS") - - st.markdown("---") - st.markdown("#### Classificação e Contexto") - col_c1, col_c2, col_c3 = st.columns([1, 1, 1]) - tipo = col_c1.selectbox("Tipo", TIPOS, index=0) - severidade = col_c2.selectbox("Severidade", SEVERIDADES, index=1) - prioridade = col_c3.selectbox("Prioridade", PRIORIDADES, index=1) - - col_c4, col_c5 = st.columns([1, 1]) - cliente = col_c4.text_input("Cliente (opcional)") - local = col_c5.text_input("Local (opcional)") - - st.markdown("---") - st.markdown("#### Descrição da Não Conformidade") - descricao_nc = st.text_area("Descrição detalhada", height=160) - - st.markdown("---") - st.markdown("#### Ação Imediata/Contenção (linha avulsa / legado)") - ac_imediata_desc = st.text_area("Ação Imediata/Contenção (disposição quando aplicável)", height=120) - col_ai1, col_ai2 = st.columns([1, 1]) - ac_imediata_resp = col_ai1.text_input("Responsável pela Ação Imediata") - ac_imediata_data = col_ai2.date_input("Data de Conclusão (Ação Imediata)", value=None, format="DD/MM/YYYY") - - # >>> Linha avulsa (legado) - st.markdown("#### Ação Corretiva (linha avulsa / legado)") - ac_corretiva_desc = st.text_area("Ação Corretiva (quando aplicável)", height=120) - col_cx1, col_cx2 = st.columns([1, 1]) - ac_corretiva_resp = col_cx1.text_input("Responsável pela Ação Corretiva") - ac_corretiva_data = col_cx2.date_input("Data de Conclusão (Ação Corretiva)", value=None, format="DD/MM/YYYY") - - st.markdown("#### Ação Preventiva (linha avulsa / legado)") - ac_preventiva_desc = st.text_area("Ação Preventiva (quando aplicável)", height=120) - col_px1, col_px2 = st.columns([1, 1]) - ac_preventiva_resp = col_px1.text_input("Responsável pela Ação Preventiva") - ac_preventiva_data = col_px2.date_input("Data de Conclusão (Ação Preventiva)", value=None, format="DD/MM/YYYY") - - # >>> Múltiplas linhas - st.markdown("---") - st.markdown("#### Ações Imediatas/Contenção (múltiplas linhas)") - if "__ai_rows__" not in st.session_state: - st.session_state["__ai_rows__"] = {} - N = st.session_state.get("__qtd_ai__", 1) - for idx in range(N): - st.caption(f"— Ação imediata #{idx+1}") - col_m1, col_m2, col_m3 = st.columns([2, 1, 1]) - prev = st.session_state["__ai_rows__"].get(idx, {}) - desc_val = prev.get("desc", "") - resp_val = prev.get("resp", "") - date_val = prev.get("date", None) - desc_i = col_m1.text_input(f"Descrição da ação #{idx+1}", value=desc_val, key=f"ai_desc_{idx}") - resp_i = col_m2.text_input(f"Responsável #{idx+1}", value=resp_val, key=f"ai_resp_{idx}") - date_i = col_m3.date_input(f"Data conclusão #{idx+1}", value=date_val, format="DD/MM/YYYY", key=f"ai_date_{idx}") - st.session_state["__ai_rows__"][idx] = {"desc": desc_i, "resp": resp_i, "date": date_i} - - st.markdown("---") - st.markdown("#### Ações Corretivas (múltiplas linhas)") - if "__ac_rows__" not in st.session_state: - st.session_state["__ac_rows__"] = {} - NC = st.session_state.get("__qtd_ac__", 0) - for idx in range(NC): - st.caption(f"— Ação corretiva #{idx+1}") - col_c1, col_c2, col_c3 = st.columns([2, 1, 1]) - prevc = st.session_state["__ac_rows__"].get(idx, {}) - desc_val = prevc.get("desc", "") - resp_val = prevc.get("resp", "") - date_val = prevc.get("date", None) - desc_i = col_c1.text_input(f"Descrição da ação corretiva #{idx+1}", value=desc_val, key=f"ac_desc_{idx}") - resp_i = col_c2.text_input(f"Responsável (corretiva) #{idx+1}", value=resp_val, key=f"ac_resp_{idx}") - date_i = col_c3.date_input(f"Data conclusão (corretiva) #{idx+1}", value=date_val, format="DD/MM/YYYY", key=f"ac_date_{idx}") - st.session_state["__ac_rows__"][idx] = {"desc": desc_i, "resp": resp_i, "date": date_i} - - st.markdown("---") - st.markdown("#### Ações Preventivas (múltiplas linhas)") - if "__ap_rows__" not in st.session_state: - st.session_state["__ap_rows__"] = {} - NP = st.session_state.get("__qtd_ap__", 0) - for idx in range(NP): - st.caption(f"— Ação preventiva #{idx+1}") - col_p1, col_p2, col_p3 = st.columns([2, 1, 1]) - prevp = st.session_state["__ap_rows__"].get(idx, {}) - desc_val = prevp.get("desc", "") - resp_val = prevp.get("resp", "") - date_val = prevp.get("date", None) - desc_i = col_p1.text_input(f"Descrição da ação preventiva #{idx+1}", value=desc_val, key=f"ap_desc_{idx}") - resp_i = col_p2.text_input(f"Responsável (preventiva) #{idx+1}", value=resp_val, key=f"ap_resp_{idx}") - date_i = col_p3.date_input(f"Data conclusão (preventiva) #{idx+1}", value=date_val, format="DD/MM/YYYY", key=f"ap_date_{idx}") - st.session_state["__ap_rows__"][idx] = {"desc": desc_i, "resp": resp_i, "date": date_i} - - st.markdown("---") - st.markdown("#### Análise das Causas") - metodologia = st.selectbox( - "Metodologia utilizada", - ["Ishikawa (Diagrama de Causa e Efeito)", "5 Porquês", "FMEA", "Análise de Processo", "Outra"], - index=0 - ) - causa_raiz = st.text_area("Causa Raiz Identificada", height=120) - - with st.expander("Metodologia Ishikawa (opcional) — preencher 1ª, 2ª, 3ª, 4ª por categoria"): - st.caption("Preencha quando a metodologia for Ishikawa. Caso contrário, deixe em branco.") - def ishikawa_inputs(label_prefix): - col_i1, col_i2 = st.columns([1, 1]) - a1 = col_i1.text_input(f"{label_prefix} — 1ª") - a2 = col_i2.text_input(f"{label_prefix} — 2ª") - col_i3, col_i4 = st.columns([1, 1]) - a3 = col_i3.text_input(f"{label_prefix} — 3ª") - a4 = col_i4.text_input(f"{label_prefix} — 4ª") - return [a1, a2, a3, a4] - - pessoa = ishikawa_inputs("Mão de Obra - Pessoa") - material = ishikawa_inputs("Material") - medida = ishikawa_inputs("Medida") - meio_ambiente = ishikawa_inputs("Meio Ambiente") - maquina = ishikawa_inputs("Máquina ou Equipamento") - metodo = ishikawa_inputs("Método") - - anexos = st.file_uploader("Anexos (opcional)", type=None, accept_multiple_files=True) - - enviar = st.form_submit_button("Salvar RNC") - - if enviar: - if not emitente or not descricao_nc: - st.warning("Preencha ao menos **Emitente** e **Descrição da Não Conformidade**.") - return - - try: - codigo = gerar_codigo_rnc(db) - - # Cabeçalho estruturado (persistido em 'descricao' antes do texto da NC) - header_md = f"""**FOR-SGQ-08 • Rev 01** -**DATA:** {data_form.strftime('%d/%m/%Y')} • **EMITENTE:** {emitente} • **RNC Nº:** {codigo} -**RNC CLIENTE Nº:** {rnc_cliente_numero or 'N/A'} • **CLIENTE EMITENTE:** {cliente_emitente or 'N/A'} -**Área Solicitante:** {area_solicitante or '—'} • **Origem:** {origem_form} • **Área Notificada:** {area_notificada or '—'} -**Envolvidos:** -- {inv1_nome or '—'} • Matr.: {inv1_matr or '—'} • Função: {inv1_func or '—'} -- {inv2_nome or '—'} • Matr.: {inv2_matr or '—'} • Função: {inv2_func or '—'} - -**Descrição da Não Conformidade:** -{descricao_nc.strip()} -""" - - # Bloco de causa raiz / metodologia (persistido em 'causa_raiz') - causa_md = f"""**Metodologia utilizada:** {metodologia} -**Causa Raiz Identificada:** -{(causa_raiz or '').strip() or '—'} -""" - - def _format_cat(nome, vals): - filas = [v for v in (vals or []) if (v or "").strip()] - if not filas: - return "" - lines = "\n".join([f"- {i+1}ª: {filas[i]}" for i in range(len(filas))]) - return f"**{nome}:**\n{lines}\n" - - ish_md_parts = [] - ish_md_parts.append(_format_cat("Mão de Obra - Pessoa", pessoa)) - ish_md_parts.append(_format_cat("Material", material)) - ish_md_parts.append(_format_cat("Medida", medida)) - ish_md_parts.append(_format_cat("Meio Ambiente", meio_ambiente)) - ish_md_parts.append(_format_cat("Máquina ou Equipamento", maquina)) - ish_md_parts.append(_format_cat("Método", metodo)) - ish_md = "\n".join([p for p in ish_md_parts if p]) - - if ish_md.strip(): - causa_md += "\n**Ishikawa (Diagrama de Causa e Efeito):**\n" + ish_md - - # Cria RNC - rnc = RNC( - codigo=codigo, - titulo=f"RNC {codigo} • {emitente}", - descricao=header_md, # cabeçalho + descrição NC - origem=origem_form, - tipo=(tipo or "").strip() or None, - severidade=(severidade or "").strip() or None, - prioridade=(prioridade or "").strip() or None, - status="Aberta", - data_abertura=datetime.utcnow(), - prazo=None, - responsavel=None, # pode ser definido depois - area_solicitante=(area_solicitante or "").strip() or None, - area_notificada=(area_notificada or "").strip() or None, - criado_por=st.session_state.get("usuario") or "desconhecido", - cliente=(cliente or "").strip() or None, - local=(local or "").strip() or None, - causa_raiz=causa_md, # bloco estruturado - emitente=emitente, - data_form=data_form, - rnc_cliente_numero=(rnc_cliente_numero or "").strip() or None, - cliente_emitente=(cliente_emitente or "").strip() or None - ) - db.add(rnc) - db.commit() - - # Ação Imediata/Contenção (LEGADO — uma linha) - if (ac_imediata_desc or "").strip(): - try: - ac = RNCAcaoCorretiva( - rnc_id=rnc.id, - descricao=f"[IMEDIATA/CONTENÇÃO] {ac_imediata_desc.strip()}", - responsavel=(ac_imediata_resp or "").strip() or None, - prazo=datetime.combine(ac_imediata_data, datetime.min.time()) if isinstance(ac_imediata_data, date) else None, - status="Concluída" if isinstance(ac_imediata_data, date) else "Em execução", - conclusao_em=datetime.combine(ac_imediata_data, datetime.min.time()) if isinstance(ac_imediata_data, date) else None, - eficacia=None - ) - db.add(ac) - db.commit() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar a Ação Imediata (linha única): {e}") - - # Ação Corretiva (LEGADO — uma linha) - if (ac_corretiva_desc or "").strip(): - try: - acx = RNCAcaoCorretiva( - rnc_id=rnc.id, - descricao=f"[Corretiva] {ac_corretiva_desc.strip()}", - responsavel=(ac_corretiva_resp or "").strip() or None, - prazo=datetime.combine(ac_corretiva_data, datetime.min.time()) if isinstance(ac_corretiva_data, date) else None, - status="Concluída" if isinstance(ac_corretiva_data, date) else "Em execução", - conclusao_em=datetime.combine(ac_corretiva_data, datetime.min.time()) if isinstance(ac_corretiva_data, date) else None, - eficacia=None - ) - db.add(acx) - db.commit() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar a Ação Corretiva (linha única): {e}") - - # Ação Preventiva (LEGADO — uma linha) - if (ac_preventiva_desc or "").strip(): - try: - apx = RNCAcaoCorretiva( - rnc_id=rnc.id, - descricao=f"[Preventiva] {ac_preventiva_desc.strip()}", - responsavel=(ac_preventiva_resp or "").strip() or None, - prazo=datetime.combine(ac_preventiva_data, datetime.min.time()) if isinstance(ac_preventiva_data, date) else None, - status="Concluída" if isinstance(ac_preventiva_data, date) else "Em execução", - conclusao_em=datetime.combine(ac_preventiva_data, datetime.min.time()) if isinstance(ac_preventiva_data, date) else None, - eficacia=None - ) - db.add(apx) - db.commit() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar a Ação Preventiva (linha única): {e}") - - # salvar múltiplas Ações Imediatas/Contenção - try: - ac_rows = st.session_state.get("__ai_rows__", {}) - N = st.session_state.get("__qtd_ai__", 1) - if ac_rows: - for idx in range(N): - row = ac_rows.get(idx, {}) - desc = (row.get("desc") or "").strip() - resp = (row.get("resp") or "").strip() or None - dt = row.get("date", None) - if desc: - ac = RNCAcaoCorretiva( - rnc_id=rnc.id, - descricao=f"[IMEDIATA/CONTENÇÃO] {desc}", - responsavel=resp, - prazo=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, - status="Concluída" if isinstance(dt, date) else "Em execução", - conclusao_em=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, - eficacia=None - ) - db.add(ac) - db.commit() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar ações imediatas (múltiplas): {e}") - - # salvar múltiplas Ações CORRETIVAS - try: - ac_rows2 = st.session_state.get("__ac_rows__", {}) - NC = st.session_state.get("__qtd_ac__", 0) - if ac_rows2 and NC > 0: - for idx in range(NC): - row = ac_rows2.get(idx, {}) - desc = (row.get("desc") or "").strip() - resp = (row.get("resp") or "").strip() or None - dt = row.get("date", None) - if desc: - acor = RNCAcaoCorretiva( - rnc_id=rnc.id, - descricao=f"[Corretiva] {desc}", - responsavel=resp, - prazo=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, - status="Concluída" if isinstance(dt, date) else "Em execução", - conclusao_em=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, - eficacia=None - ) - db.add(acor) - db.commit() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar ações corretivas (múltiplas): {e}") - - # salvar múltiplas Ações PREVENTIVAS - try: - ap_rows2 = st.session_state.get("__ap_rows__", {}) - NP = st.session_state.get("__qtd_ap__", 0) - if ap_rows2 and NP > 0: - for idx in range(NP): - row = ap_rows2.get(idx, {}) - desc = (row.get("desc") or "").strip() - resp = (row.get("resp") or "").strip() or None - dt = row.get("date", None) - if desc: - apre = RNCAcaoCorretiva( - rnc_id=rnc.id, - descricao=f"[Preventiva] {desc}", - responsavel=resp, - prazo=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, - status="Concluída" if isinstance(dt, date) else "Em execução", - conclusao_em=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, - eficacia=None - ) - db.add(apre) - db.commit() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar ações preventivas (múltiplas): {e}") - - # Anexos - if anexos: - dest_dir = os.path.join(UPLOAD_DIR, rnc.codigo) - _ensure_upload_dir(dest_dir) - for up in anexos: - dest_path = os.path.join(dest_dir, up.name) - with open(dest_path, "wb") as f: - f.write(up.getbuffer()) - anexo = RNCAnexo( - rnc_id=rnc.id, - nome_arquivo=up.name, - caminho=dest_path, - conteudo_tipo=getattr(up, "type", None), - enviado_por=st.session_state.get("usuario"), - enviado_em=datetime.utcnow() - ) - db.add(anexo) - db.commit() - - # Auditoria - _registrar_log(st.session_state.get("usuario"), f"Criou RNC {rnc.codigo}", "rnc", rnc.id) - - # Notificação (opcional) - emails = [] - criador_email = _get_user_email(rnc.criado_por) - if criador_email: - emails.append(criador_email) - assunto = f"[IOI-RUN] Nova RNC criada: {rnc.codigo}" - corpo = f"""Uma nova RNC foi criada. - -Código: {rnc.codigo} -Emitente: {emitente} -Origem: {origem_form} -Tipo: {rnc.tipo or '—'} | Severidade: {rnc.severidade or '—'} | Prioridade: {rnc.prioridade or '—'} -Área Solicitante: {area_solicitante or '—'} | Área Notificada: {area_notificada or '—'} -Cliente: {rnc.cliente or '—'} | Local: {rnc.local or '—'} -Abertura: {rnc.data_abertura.strftime('%d/%m/%Y %H:%M')} - -Descrição da NC: -{descricao_nc.strip()} -""" - _send_email_para_responsavel(assunto, corpo, emails) - - st.success(f"RNC **{rnc.codigo}** criada com sucesso!") - st.rerun() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar a RNC: {e}") - - -# =============================== -# EDIÇÃO de RNC existente -# =============================== -def _extrair_descricao_nc(descricao_md: str) -> str: - """Tenta extrair o corpo após o marcador '**Descrição da Não Conformidade:**'.""" - if not descricao_md: - return "" - marker = "**Descrição da Não Conformidade:**" - if marker in descricao_md: - try: - return descricao_md.split(marker, 1)[1].strip() - except Exception: - return descricao_md - return descricao_md - - -def _montar_descricao_md(data_form: date, emitente: str, codigo: str, - rnc_cliente_numero: Optional[str], cliente_emitente: Optional[str], - area_solicitante: Optional[str], origem_form: str, area_notificada: Optional[str], - inv1: Dict[str, str], inv2: Dict[str, str], - descricao_nc: str) -> str: - header_md = f"""**FOR-SGQ-08 • Rev 01** -**DATA:** {data_form.strftime('%d/%m/%Y')} • **EMITENTE:** {emitente} • **RNC Nº:** {codigo} -**RNC CLIENTE Nº:** {rnc_cliente_numero or 'N/A'} • **CLIENTE EMITENTE:** {cliente_emitente or 'N/A'} -**Área Solicitante:** {area_solicitante or '—'} • **Origem:** {origem_form} • **Área Notificada:** {area_notificada or '—'} -**Envolvidos:** -- {inv1.get('nome') or '—'} • Matr.: {inv1.get('matr') or '—'} • Função: {inv1.get('func') or '—'} -- {inv2.get('nome') or '—'} • Matr.: {inv2.get('matr') or '—'} • Função: {inv2.get('func') or '—'} - -**Descrição da Não Conformidade:** -{(descricao_nc or '').strip()} -""" - return header_md - - -def _editar_rnc(db): - st.subheader("✏️ Editar RNC existente") - - # Controles de busca - col_s1, col_s2, col_s3, col_s4 = st.columns([1.2, 1.6, 0.6, 0.6]) - cod_informado = col_s1.text_input("Informe o código (ex.: RNC-2026-0001)", key="__rnc_edit_code__") - # últimas 30 - recentes = db.query(RNC).order_by(RNC.data_abertura.desc()).limit(30).all() - opts = [f"{r.codigo} — {r.titulo or '—'}" for r in recentes] - sel = col_s2.selectbox("ou selecione uma RNC recente", ["(nenhuma)"] + opts, index=0) - trigger = col_s3.button("Carregar") - limpar = col_s4.button("Limpar seleção") - - # Botão limpar: NÃO alterar __rnc_edit_code__ para evitar erro do Streamlit - if limpar: - st.session_state.pop("__rnc_edit_id__", None) - st.rerun() - - # Botão carregar - if trigger: - # Se "(nenhuma)" e sem código — limpar seleção anterior - if sel == "(nenhuma)" and not (cod_informado or "").strip(): - st.session_state.pop("__rnc_edit_id__", None) - st.info("Nenhuma RNC selecionada. A seleção foi limpa.") - st.rerun() - - # Resolve código digitado ou selecionado - codigo = (cod_informado or "").strip() or (sel.split(" — ")[0] if sel != "(nenhuma)" else "") - alvo = db.query(RNC).filter(RNC.codigo == codigo).first() if codigo else None - - if not alvo: - st.session_state.pop("__rnc_edit_id__", None) - st.warning("RNC não encontrada ou nenhuma selecionada. A seleção foi limpa.") - st.rerun() - else: - st.session_state["__rnc_edit_id__"] = alvo.id - st.success(f"RNC {alvo.codigo} carregada para edição.") - st.rerun() - - # RNC carregada - rnc = db.query(RNC).filter(RNC.id == st.session_state.get("__rnc_edit_id__")).first() if st.session_state.get("__rnc_edit_id__") else None - - if not rnc: - st.info("Carregue uma RNC para editar.") - return - - # Form de edição - with st.form(f"form_edit_rnc_{rnc.id}"): - st.markdown(f"**Editando:** {rnc.codigo} • {rnc.titulo or '—'}") - - col_eh1, col_eh2, col_eh3 = st.columns([1, 1, 1]) - data_form = col_eh1.date_input("DATA", value=rnc.data_form or date.today(), format="DD/MM/YYYY") - emitente = col_eh2.text_input("EMITENTE", value=rnc.emitente or "") - origem_form = col_eh3.selectbox("Origem", ORIGENS_FORMS, index=(ORIGENS_FORMS.index(rnc.origem) if rnc.origem in ORIGENS_FORMS else 2)) - - col_eh4, col_eh5 = st.columns([1, 1]) - rnc_cliente_numero = col_eh4.text_input("RNC CLIENTE Nº (opcional)", value=rnc.rnc_cliente_numero or "") - cliente_emitente = col_eh5.text_input("CLIENTE EMITENTE (opcional)", value=rnc.cliente_emitente or "") - - col_h6, col_h7 = st.columns([1, 1]) - area_solicitante = col_h6.text_input("Área Solicitante", value=rnc.area_solicitante or "") - area_notificada = col_h7.text_input("Área Notificada", value=rnc.area_notificada or "") - - st.markdown("##### Classificação") - col_c1, col_c2, col_c3 = st.columns([1, 1, 1]) - tipo = col_c1.selectbox("Tipo", TIPOS, index=(TIPOS.index(rnc.tipo) if rnc.tipo in TIPOS else 0)) - severidade = col_c2.selectbox("Severidade", SEVERIDADES, index=(SEVERIDADES.index(rnc.severidade) if rnc.severidade in SEVERIDADES else 1)) - prioridade = col_c3.selectbox("Prioridade", PRIORIDADES, index=(PRIORIDADES.index(rnc.prioridade) if rnc.prioridade in PRIORIDADES else 1)) - - col_c4, col_c5 = st.columns([1, 1]) - cliente = col_c4.text_input("Cliente (opcional)", value=rnc.cliente or "") - local = col_c5.text_input("Local (opcional)", value=rnc.local or "") - - st.markdown("##### Atribuição e Prazo") - col_as1, col_as2, col_as3 = st.columns([1, 1, 1]) - responsavel = col_as1.text_input("Responsável", value=rnc.responsavel or "") - prazo = col_as2.date_input("Prazo (conclusão)", value=(rnc.prazo.date() if isinstance(rnc.prazo, datetime) else rnc.prazo) if rnc.prazo else None, format="DD/MM/YYYY") - status = col_as3.selectbox("Status", STATUS_OPCOES, index=(STATUS_OPCOES.index(rnc.status) if rnc.status in STATUS_OPCOES else 0)) - - st.markdown("##### Descrição e Causa Raiz") - # tenta extrair apenas a descrição da NC a partir do markdown original - desc_nc_atual = _extrair_descricao_nc(rnc.descricao or "") - descricao_nc = st.text_area("Descrição da Não Conformidade (substituir corpo)", value=desc_nc_atual or "", height=140) - causa_raiz = st.text_area("Causa Raiz Identificada", value=rnc.causa_raiz or "", height=120) - - # Envolvidos (mantemos inputs simples; no cadastro ficam no cabeçalho) - with st.expander("Envolvidos (opcional)"): - col_en1, col_en2, col_en3 = st.columns([2, 1, 1]) - inv1_nome = col_en1.text_input("Envolvido 1 — Nome", value="") - inv1_matr = col_en2.text_input("Matrícula 1", value="") - inv1_func = col_en3.text_input("Função 1", value="") - - col_en4, col_en5, col_en6 = st.columns([2, 1, 1]) - inv2_nome = col_en4.text_input("Envolvido 2 — Nome", value="") - inv2_matr = col_en5.text_input("Matrícula 2", value="") - inv2_func = col_en6.text_input("Função 2", value="") - - salvar = st.form_submit_button("💾 Salvar alterações") - - if salvar: - usuario = st.session_state.get("usuario") - perfil = (st.session_state.get("perfil") or "user").lower() - - # Permissão para Encerrar/Cancelar - if status in ["Encerrada", "Cancelada"] and not can_close_or_cancel(perfil): - st.warning("Somente administradores podem **encerrar** ou **cancelar** RNC.") - return - - try: - # Atualiza campos básicos - rnc.emitente = (emitente or "").strip() or None - rnc.origem = origem_form - rnc.rnc_cliente_numero = (rnc_cliente_numero or "").strip() or None - rnc.cliente_emitente = (cliente_emitente or "").strip() or None - rnc.area_solicitante = (area_solicitante or "").strip() or None - rnc.area_notificada = (area_notificada or "").strip() or None - rnc.tipo = (tipo or "").strip() or None - rnc.severidade = (severidade or "").strip() or None - rnc.prioridade = (prioridade or "").strip() or None - rnc.cliente = (cliente or "").strip() or None - rnc.local = (local or "").strip() or None - rnc.responsavel = (responsavel or "").strip() or None - rnc.prazo = datetime.combine(prazo, datetime.min.time()) if isinstance(prazo, date) else None - rnc.status = status - rnc.data_form = data_form - rnc.titulo = f"RNC {rnc.codigo} • {emitente or '—'}" - rnc.causa_raiz = (causa_raiz or "").strip() or None - - # Reconstrói o markdown de 'descricao' no mesmo padrão do cadastro - inv1 = {"nome": inv1_nome, "matr": inv1_matr, "func": inv1_func} - inv2 = {"nome": inv2_nome, "matr": inv2_matr, "func": inv2_func} - rnc.descricao = _montar_descricao_md( - data_form=data_form, - emitente=emitente, - codigo=rnc.codigo, - rnc_cliente_numero=rnc_cliente_numero, - cliente_emitente=cliente_emitente, - area_solicitante=area_solicitante, - origem_form=origem_form, - area_notificada=area_notificada, - inv1=inv1, - inv2=inv2, - descricao_nc=descricao_nc, - ) - - # Encerramento: registra data - if status == "Encerrada": - rnc.encerrada_em = datetime.utcnow() - elif status != "Encerrada": - rnc.encerrada_em = None - - db.add(rnc) - db.commit() - - _registrar_log(usuario, f"Editou RNC {rnc.codigo}", "rnc", rnc.id) - st.success("RNC atualizada com sucesso!") - st.rerun() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar alterações: {e}") - - -# =============================== -# Anexos / Plano de Ação / Timeline / Aprovação -# =============================== -def _mostrar_anexos(rnc: RNC, db): - st.markdown("**Anexos**") - if rnc.anexos: - for an in rnc.anexos: - st.caption(f"📎 {an.nome_arquivo} • {an.enviado_por or '—'} • {an.enviado_em.strftime('%d/%m/%Y %H:%M')}") - if os.path.exists(an.caminho): - try: - with open(an.caminho, "rb") as f: - st.download_button(f"Baixar {an.nome_arquivo}", f.read(), file_name=an.nome_arquivo, key=f"dl_{rnc.id}_{an.id}") - except Exception: - st.caption("Arquivo indisponível.") - else: - st.caption("Sem anexos") - - up_more = st.file_uploader(f"Adicionar anexos — {rnc.codigo}", accept_multiple_files=True, key=f"up_{rnc.id}") - if up_more: - dest_dir = os.path.join(UPLOAD_DIR, rnc.codigo) - _ensure_upload_dir(dest_dir) - try: - for up in up_more: - dest_path = os.path.join(dest_dir, up.name) - with open(dest_path, "wb") as f: - f.write(up.getbuffer()) - anexo = RNCAnexo( - rnc_id=rnc.id, - nome_arquivo=up.name, - caminho=dest_path, - conteudo_tipo=getattr(up, "type", None), - enviado_por=st.session_state.get("usuario"), - enviado_em=datetime.utcnow() - ) - db.add(anexo) - db.commit() - st.success("Anexos adicionados.") - st.rerun() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar anexos: {e}") - - -def _mostrar_plano_acao(rnc: RNC, db): - st.markdown("### 🛠️ Plano de Ação — Separado por categorias") - - def _cat(descricao: str) -> str: - s = (descricao or "").strip() - if s.startswith("[IMEDIATA/CONTENÇÃO]"): - return "imediata" - elif s.startswith("[Preventiva]"): - return "preventiva" - elif s.startswith("[Corretiva]"): - return "corretiva" - else: - return "outras" - - acoes = rnc.acoes or [] - acoes_imediatas = [a for a in acoes if _cat(a.descricao) == "imediata"] - acoes_corretivas = [a for a in acoes if _cat(a.descricao) == "corretiva"] - acoes_preventivas = [a for a in acoes if _cat(a.descricao) == "preventiva"] - acoes_outras = [a for a in acoes if _cat(a.descricao) == "outras"] - - def _render_bloco(titulo: str, lista: List[RNCAcaoCorretiva]): - st.markdown(f"#### {titulo}") - if not lista: - st.caption("— Nenhuma ação cadastrada nesta categoria.") - return - lista = sorted(lista, key=lambda a: (a.status, a.prazo or datetime.max)) - for ac in lista: - line = f"- {ac.descricao}" - line += f" • Resp: {ac.responsavel or '—'}" - line += f" • Prazo: {ac.prazo.strftime('%d/%m/%Y') if ac.prazo else '—'}" - line += f" • Status: {ac.status}" - if ac.eficacia: - line += f" • Eficácia: {ac.eficacia}" - st.write(line) - - with st.expander(f"Atualizar ação • ID {ac.id}", expanded=False): - col_s1, col_s2, col_s3 = st.columns(3) - novo_status = col_s1.selectbox( - "Status", - ["Planejada", "Em execução", "Concluída", "Ineficaz"], - index=["Planejada", "Em execução", "Concluída", "Ineficaz"].index(ac.status) - if ac.status in ["Planejada", "Em execução", "Concluída", "Ineficaz"] else 0, - key=f"ac_st_{ac.id}" - ) - nova_eficacia = col_s2.selectbox( - "Eficácia (resultado da ação)", - ["(não avaliada)", "Eficaz", "Parcial", "Ineficaz"], - index=0 if not ac.eficacia else ["(não avaliada)", "Eficaz", "Parcial", "Ineficaz"].index( - ac.eficacia if ac.eficacia in ["Eficaz", "Parcial", "Ineficaz"] else "(não avaliada)" - ), - key=f"ac_eff_{ac.id}" - ) - concluir = col_s3.button("Salvar atualização", key=f"ac_sv_{ac.id}") - - if concluir: - try: - ac.status = novo_status - if novo_status == "Concluída": - ac.conclusao_em = datetime.utcnow() - ac.eficacia = None if nova_eficacia == "(não avaliada)" else nova_eficacia - db.add(ac) - db.commit() - _registrar_log(st.session_state.get("usuario"), f"Atualizou ação RNC {rnc.codigo} (ID ação {ac.id})", "rnc_acao", ac.id) - st.success("Ação atualizada.") - st.rerun() - except Exception as e: - db.rollback() - st.error(f"Erro ao atualizar ação: {e}") - - _render_bloco("🚀 Ações Imediatas/Contenção", acoes_imediatas) - _render_bloco("🔧 Ações Corretivas", acoes_corretivas) - _render_bloco("🛡️ Ações Preventivas", acoes_preventivas) - _render_bloco("📁 Outras (sem prefixo)", acoes_outras) - - # Form nova ação - with st.form(f"form_new_action_{rnc.id}"): - col_a1, col_a2, col_a3, col_a4 = st.columns([1.6, 1, 1, 1]) - ac_tipo = col_a1.selectbox("Tipo da ação", ["Imediata/Contenção", "Corretiva", "Preventiva"], index=1, key=f"ac_tipo_{rnc.id}") - ac_desc = col_a1.text_input("Descrição da ação", key=f"ac_desc_{rnc.id}") - ac_resp = col_a2.text_input("Responsável", key=f"ac_resp_{rnc.id}") - ac_prazo = col_a3.date_input("Data de Conclusão (prazo)", value=None, format="DD/MM/YYYY", key=f"ac_prazo_{rnc.id}") - ac_enviar = col_a4.form_submit_button("Adicionar ação") - if ac_enviar: - if not ac_desc: - st.warning("Informe a descrição da ação.") - else: - try: - tipo_prefix = "[IMEDIATA/CONTENÇÃO]" if ac_tipo == "Imediata/Contenção" else ("[Preventiva]" if ac_tipo == "Preventiva" else "[Corretiva]") - new_ac = RNCAcaoCorretiva( - rnc_id=rnc.id, - descricao=f"{tipo_prefix} {ac_desc.strip()}", - responsavel=(ac_resp or "").strip() or None, - prazo=datetime.combine(ac_prazo, datetime.min.time()) if isinstance(ac_prazo, date) else None, - status="Planejada" if not isinstance(ac_prazo, date) else "Concluída", - conclusao_em=datetime.combine(ac_prazo, datetime.min.time()) if isinstance(ac_prazo, date) else None - ) - db.add(new_ac) - db.commit() - _registrar_log(st.session_state.get("usuario"), f"Adicionou ação em {rnc.codigo}", "rnc_acao", new_ac.id) - st.success("Ação adicionada.") - st.rerun() - except Exception as e: - db.rollback() - st.error(f"Erro ao salvar ação: {e}") - - -def _mostrar_verificacao_eficacia(rnc: RNC, db, usuario: str, perfil: str): - st.markdown("### ✅ Verificação da Eficácia e Aprovação") - st.caption("Descreva como a eficácia será monitorada e registre o resultado das ações. No caso de **Não Eficaz**, a RNC será reaberta.") - with st.form(f"form_verif_{rnc.id}"): - plano = st.text_area("O que será feito para manter e acompanhar a eficácia das ações aplicadas?", height=120, key=f"verif_plano_{rnc.id}") - col_v1, col_v2, col_v3 = st.columns([1, 1, 1]) - resultado = col_v1.selectbox("Resultado das ações", ["(pendente)", "Eficaz", "Não Eficaz"], index=0, key=f"verif_res_{rnc.id}") - encerrar_agora = col_v2.checkbox("Encerrar RNC (se Eficaz)", value=False, key=f"verif_close_{rnc.id}") - resp_enc = col_v3.text_input("Responsável pelo encerramento (opcional)", value=usuario, key=f"verif_enc_{rnc.id}") - enviar = st.form_submit_button("Registrar verificação") - if enviar: - if not plano.strip() and resultado == "(pendente)": - st.warning("Registre o plano de verificação ou o resultado.") - return - try: - msg = f"[Verificação da Eficácia]\n{plano.strip() or '(sem plano)'}\n\nResultado: {resultado}" - cm = RNCComentario( - rnc_id=rnc.id, - autor=usuario, - mensagem=msg, - data=datetime.utcnow() - ) - db.add(cm) - - # Regras: se Não Eficaz → reabrir (Em Análise). Se Eficaz e encerrar → Encerrada. - if resultado == "Não Eficaz": - rnc.status = "Em Análise" - rnc.encerrada_em = None - db.add(rnc) - elif resultado == "Eficaz" and encerrar_agora and can_close_or_cancel(perfil): - rnc.status = "Encerrada" - rnc.encerrada_em = datetime.utcnow() - cm2 = RNCComentario( - rnc_id=rnc.id, - autor=resp_enc or usuario, - mensagem=f"[Encerramento] RNC encerrada por {resp_enc or usuario} em {datetime.utcnow().strftime('%d/%m/%Y %H:%M')}", - status_novo="Encerrada", - data=datetime.utcnow() - ) - db.add(cm2) - db.add(rnc) - db.commit() - - _registrar_log(usuario, f"Verificação de eficácia em {rnc.codigo} (resultado: {resultado})", "rnc", rnc.id) - st.success("Verificação registrada.") - st.rerun() - except Exception as e: - db.rollback() - st.error(f"Erro ao registrar verificação: {e}") - - -def _mostrar_timeline(rnc: RNC, db, usuario: str, perfil: str): - st.markdown("### 🕒 Timeline / Andamento") - if rnc.comentarios: - for cm in sorted(rnc.comentarios, key=lambda c: c.data, reverse=True): - line = f"**{cm.data.strftime('%d/%m/%Y %H:%M')}** • {cm.autor}: {cm.mensagem}" - changes = [] - if cm.status_novo: - changes.append(f"Status → {cm.status_novo}") - if cm.responsavel_novo: - changes.append(f"Responsável → {cm.responsavel_novo}") - if cm.prazo_novo: - changes.append(f"Prazo → {cm.prazo_novo.strftime('%d/%m/%Y')}") - if changes: - line += " \n" + " • ".join(changes) - st.write(line) - else: - st.caption("Sem comentários ainda.") - - st.markdown("#### Comentário/Atualização") - with st.form(f"form_comment_{rnc.id}"): - msg = st.text_area("Comentário/atualização (ex.: progresso, decisões)", height=80, key=f"cm_msg_{rnc.id}") - col_u1, col_u2, col_u3, col_u4 = st.columns(4) - novo_status = col_u1.selectbox("Atualizar status", ["(sem mudança)"] + STATUS_OPCOES, index=0, key=f"cm_st_{rnc.id}") - novo_resp = col_u2.text_input("Atualizar responsável", key=f"cm_resp_{rnc.id}") - novo_prazo = col_u3.date_input("Atualizar prazo", value=None, format="DD/MM/YYYY", key=f"cm_prazo_{rnc.id}") - salvar_cm = col_u4.form_submit_button("Registrar") - - if salvar_cm: - if not msg.strip() and novo_status == "(sem mudança)" and not novo_resp and not novo_prazo: - st.warning("Registre um comentário ou alguma mudança.") - return - - aplicar_mudancas = can_edit(rnc, usuario, perfil) - status_desejado = None if novo_status == "(sem mudança)" else novo_status - - if status_desejado in ["Encerrada", "Cancelada"] and not can_close_or_cancel(perfil): - st.warning("Somente administradores podem **encerrar** ou **cancelar** RNC.") - aplicar_mudancas = False # não aplicar status proibido - - try: - cm = RNCComentario( - rnc_id=rnc.id, - autor=usuario, - mensagem=((msg or "").strip() or "(sem mensagem)"), - status_novo=status_desejado, - responsavel_novo=(novo_resp or "").strip() or None, - prazo_novo=datetime.combine(novo_prazo, datetime.min.time()) if isinstance(novo_prazo, date) else None, - data=datetime.utcnow() - ) - db.add(cm) - - # Aplica mudanças - if aplicar_mudancas: - if status_desejado: - rnc.status = status_desejado - if status_desejado == "Encerrada": - rnc.encerrada_em = datetime.utcnow() - if cm.responsavel_novo: - rnc.responsavel = cm.responsavel_novo - if cm.prazo_novo: - rnc.prazo = cm.prazo_novo - db.add(rnc) - db.commit() - - _registrar_log(usuario, f"Atualizou RNC {rnc.codigo}", "rnc", rnc.id) - - # Notificação (opcional) - if status_desejado in ["Verificação", "Encerrada"]: - emails = [] - if rnc.responsavel: - resp_email = _get_user_email(rnc.responsavel) - if resp_email: - emails.append(resp_email) - criador_email = _get_user_email(rnc.criado_por) - if criador_email: - emails.append(criador_email) - assunto = f"[IOI-RUN] RNC {rnc.codigo} — Status: {status_desejado}" - corpo = f"""A RNC {rnc.codigo} teve seu status atualizado para: {status_desejado}. - -Título: {rnc.titulo} -Responsável: {rnc.responsavel or '—'} -Prazo: {rnc.prazo.strftime('%d/%m/%Y') if rnc.prazo else '—'} -Atualizado por: {usuario} -Data: {datetime.utcnow().strftime('%d/%m/%Y %H:%M')} - -Comentário: -{(msg or '').strip() or '(sem mensagem)'}""" - _send_email_para_responsavel(assunto, corpo, emails) - - st.success("Atualização registradoa.") - st.rerun() - except Exception as e: - db.rollback() - st.error(f"Erro ao registrar atualização: {e}") - - -def _mostrar_analise_aprovacao(rnc: RNC, db, usuario: str): - st.markdown("### 🧾 Análise Crítica e Aprovação") - st.caption("Registre os responsáveis pela análise crítica (QSMS) e aprovação (Diretoria).") - with st.form(f"form_aprov_{rnc.id}"): - st.markdown("**Análise Crítica (QSMS)**") - col_a1, col_a2 = st.columns([1, 1]) - analise_nome = col_a1.text_input("Nome (QSMS)", key=f"ap_nome_{rnc.id}") - analise_setor = col_a2.text_input("Setor (QSMS)", value="QSMS", key=f"ap_setor_{rnc.id}") - - st.markdown("**Aprovação (Diretoria)**") - col_b1, col_b2 = st.columns([1, 1]) - aprov_nome = col_b1.text_input("Nome (Diretoria)", key=f"ap_dir_nome_{rnc.id}") - aprov_setor = col_b2.text_input("Setor (Diretoria)", value="Diretor Executivo e de Operações", key=f"ap_dir_setor_{rnc.id}") - - enviar = st.form_submit_button("Registrar análise/aprovação") - if enviar: - try: - msg = f"[Análise Crítica / Aprovação]\nQSMS: {analise_nome or '—'} • Setor: {analise_setor or '—'}\nDiretoria: {aprov_nome or '—'} • Setor: {aprov_setor or '—'}" - cm = RNCComentario( - rnc_id=rnc.id, - autor=usuario, - mensagem=msg, - data=datetime.utcnow() - ) - db.add(cm) - db.commit() - except Exception as e: - db.rollback() - st.error(f"Erro ao registrar análise/aprovação: {e}") - - -# =============================== -# Tema/Background específico do módulo RNC -# =============================== -def _apply_rnc_theme(): - """ - Aplica um tema visual específico para a página do módulo RNC - (background diferenciado, header destacado e sutis ajustes visuais). - """ - st.markdown( - """ - - """, - unsafe_allow_html=True - ) - - -# =============================== -# Página principal (container) -# =============================== -def _card_rnc_header(rnc: RNC): - col_a, col_b = st.columns([3, 1]) - with col_a: - st.markdown(f"**{rnc.codigo}** — {rnc.titulo or '—'}") - st.caption( - f"Origem: {rnc.origem or '—'} • " - f"Tipo: {rnc.tipo or '—'} • " - f"Severidade: {rnc.severidade or '—'} • " - f"Prioridade: {rnc.prioridade or '—'}" - ) - st.caption( - f"Responsável: {rnc.responsavel or '—'} • " - f"Prazo: {rnc.prazo.strftime('%d/%m/%Y') if rnc.prazo else '—'} • " - f"Abertura: {rnc.data_abertura.strftime('%d/%m/%Y %H:%M') if rnc.data_abertura else '—'}" - ) - with col_b: - col_b.metric("Status", rnc.status or "—") - - -def _blocos_rnc(rnc: RNC, db, usuario: str, perfil: str): - st.markdown("---") - # Conteúdo principal do card/expander - st.markdown("#### 📄 Cabeçalho / Descrição") - st.markdown(rnc.descricao or "—") - - st.markdown("---") - _mostrar_anexos(rnc, db) - - st.markdown("---") - _mostrar_plano_acao(rnc, db) - - st.markdown("---") - _mostrar_timeline(rnc, db, usuario, perfil) - - st.markdown("---") - _mostrar_verificacao_eficacia(rnc, db, usuario, perfil) - - st.markdown("---") - _mostrar_analise_aprovacao(rnc, db, usuario) - - -def pagina(): - """Tela principal do módulo RNC (cadastro + edição).""" - _apply_rnc_theme() - - st.header("RNC • Registro de Não Conformidades (FOR-SGQ-08 Rev 01)") - - usuario = st.session_state.get("usuario") or "desconhecido" - perfil = (st.session_state.get("perfil") or "user").lower() - - db = SessionLocal() - try: - # KPIs - _kpis_area(db, usuario, perfil) - st.divider() - - # Formulário de Nova RNC - _form_nova_rnc(db) - st.divider() - - # ✏️ Edição de RNC existente (sem listagem/export aqui) - _editar_rnc(db) - - # Se houver RNC carregada para edição, renderiza blocos auxiliares - if st.session_state.get("__rnc_edit_id__"): - rnc = db.query(RNC).filter(RNC.id == st.session_state["__rnc_edit_id__"]).first() - if rnc: - with st.expander(f"📂 Detalhes • {rnc.codigo} — {rnc.titulo or '—'}", expanded=False): - _card_rnc_header(rnc) - _blocos_rnc(rnc, db, usuario, perfil) - - except Exception as e: - st.error(f"Erro ao montar a página de RNC: {e}") - finally: - try: - db.close() - except Exception: - pass - - -# Alias opcional para compatibilidade com app.py -def view(): - """Alias de pagina() para compatibilidade.""" - return pagina() - - - - - - + +# rnc.py +# -*- coding: utf-8 -*- +""" +Módulo: RNC • Registro de Não Conformidades (FOR-SGQ-08 Rev 01) +Recursos: +- Cadastro de RNC com campos do formulário FOR-SGQ-08 (cabeçalho, descrição) +- KPIs (cards) +- Edição de RNC existente (carrega por código ou lista recente) +- Timeline/comentários com atualização de status/prazo/responsável (regras de permissão) +- Plano de Ação (imediata, corretiva, preventiva) – criar e atualizar status/eficácia +- Anexos (upload incremental) +- Auditoria (utils_auditoria.registrar_log) — opcional +- Notificações por e-mail (opcional) se utils_email ou utils_notificacao existir +""" + +import os +from datetime import datetime, date +from typing import Optional, Dict, List + +import streamlit as st + +from banco import SessionLocal +from models import RNC, RNCComentario, RNCAcaoCorretiva, RNCAnexo + + +# =============================== +# Configurações do módulo +# =============================== + +# Diretório para anexos: pode definir via .env (RNC_UPLOAD_DIR) +UPLOAD_DIR = os.getenv("RNC_UPLOAD_DIR", os.path.join("uploads", "rnc")) + +# Controle de permissões +ALLOW_CREATOR_OR_RESP_TO_UPDATE = True # criador ou responsável podem atualizar campos +ONLY_ADMIN_CAN_CLOSE_OR_CANCEL = True # apenas admin pode encerrar/cancelar + +# Status, severidade etc. +STATUS_OPCOES = [ + "Aberta", "Em Análise", "Plano de Ação", + "Implementada", "Verificação", "Encerrada", "Cancelada" +] +SEVERIDADES = ["Crítica", "Maior", "Menor"] +PRIORIDADES = ["Alta", "Média", "Baixa"] +TIPOS = ["Produto", "Processo", "Sistema", "Documentação", "Outro"] + +ORIGENS_FORMS = ["Auditoria Interna", "Auditoria Externa", "Outras"] + + +# =============================== +# Utilidades auxiliares +# =============================== +def _ensure_upload_dir(path: str): + try: + os.makedirs(path, exist_ok=True) + except Exception: + pass + + +def _registrar_log(usuario: Optional[str], acao: str, tabela: str, registro_id: Optional[int] = None): + try: + from utils_auditoria import registrar_log + registrar_log(usuario=usuario, acao=acao, tabela=tabela, registro_id=registro_id) + except Exception: + # fallback silencioso + pass + + +def _send_email_para_responsavel(assunto: str, corpo: str, destinatarios: List[str]): + """Envia e-mail (opcional). Tenta utils_email ou utils_notificacao. Ignora se indisponível.""" + if not destinatarios: + return + try: + from utils_email import send_email # assinatura esperada: (to, subject, body) + for to in destinatarios: + try: + send_email(to, assunto, corpo) + except Exception: + pass + return + except Exception: + pass + + try: + from utils_notificacao import send_email # alternativa + for to in destinatarios: + try: + send_email(to, assunto, corpo) + except Exception: + pass + return + except Exception: + pass + + +def _get_user_email(login: Optional[str]) -> Optional[str]: + """Tenta obter e-mail do usuário em st.session_state.""" + # Ajuste conforme sua infra. Aqui utiliza e-mail da sessão, se existir. + return st.session_state.get("email") + + +def gerar_codigo_rnc(db) -> str: + """Gera código sequencial anual: RNC-YYYY-XXXX (4 dígitos).""" + ano = datetime.utcnow().year + prefixo = f"RNC-{ano}-" + ultimo = ( + db.query(RNC) + .filter(RNC.codigo.like(f"{prefixo}%")) + .order_by(RNC.codigo.desc()) + .first() + ) + if ultimo and ultimo.codigo and ultimo.codigo.startswith(prefixo): + try: + seq = int(ultimo.codigo.split("-")[-1]) + 1 + except Exception: + seq = 1 + else: + seq = 1 + return f"{prefixo}{seq:04d}" + + +def _is_admin(perfil: str) -> bool: + return (perfil or "").lower() == "admin" + + +def can_edit(rnc: RNC, usuario: Optional[str], perfil: str) -> bool: + """Pode editar campos (status/prazo/responsável)?""" + if _is_admin(perfil): + return True + if not ALLOW_CREATOR_OR_RESP_TO_UPDATE: + return False + if not usuario: + return False + return usuario == (rnc.criado_por or "") or usuario == (rnc.responsavel or "") + + +def can_close_or_cancel(perfil: str) -> bool: + """Pode ENCERRAR ou CANCELAR?""" + if ONLY_ADMIN_CAN_CLOSE_OR_CANCEL: + return _is_admin(perfil) + return True + + +# =============================== +# KPIs +# =============================== +def _kpis_area(db, usuario: str, perfil: str): + total = db.query(RNC).count() + em_andamento = db.query(RNC).filter(~RNC.status.in_(["Encerrada", "Cancelada"])).count() + atrasadas = db.query(RNC).filter( + RNC.prazo != None, + RNC.prazo < datetime.utcnow(), + ~RNC.status.in_(["Encerrada", "Cancelada"]) + ).count() + minhas = db.query(RNC).filter( + (RNC.responsavel == usuario) | (RNC.criado_por == usuario) + ).count() + + col1, col2, col3, col4 = st.columns(4) + col1.metric("Total RNC", total) + col2.metric("Em andamento", em_andamento) + col3.metric("Atrasadas", atrasadas, help="Prazo vencido e não encerradas/canceladas") + col4.metric("Minhas (resp./criadas)", minhas) + + +# =============================== +# Formulário: Nova RNC (FOR-SGQ-08) +# =============================== +def _form_nova_rnc(db): + st.subheader("➕ Nova RNC • FOR-SGQ-08") + + # >>> Controles fora do form para permitir re-render dinâmico das linhas de Ações + qtd_ai = st.number_input( + "Quantidade de ações imediatas", + min_value=1, max_value=15, + value=st.session_state.get("__qtd_ai__", 1), + step=1, + help="Ajuste aqui o número de linhas de ações imediatas (Descrição/Responsável/Data)." + ) + st.session_state["__qtd_ai__"] = int(qtd_ai) + + qtd_ac = st.number_input( + "Quantidade de ações CORRETIVAS", + min_value=0, max_value=25, + value=st.session_state.get("__qtd_ac__", 0), + step=1, + help="Defina o número de linhas para Ações Corretivas (Descrição/Responsável/Data)." + ) + st.session_state["__qtd_ac__"] = int(qtd_ac) + + qtd_ap = st.number_input( + "Quantidade de ações PREVENTIVAS", + min_value=0, max_value=25, + value=st.session_state.get("__qtd_ap__", 0), + step=1, + help="Defina o número de linhas para Ações Preventivas (Descrição/Responsável/Data)." + ) + st.session_state["__qtd_ap__"] = int(qtd_ap) + + with st.form("form_nova_rnc"): + st.markdown("#### Cabeçalho do Formulário") + col_h1, col_h2, col_h3 = st.columns([1, 1, 1]) + data_form = col_h1.date_input("DATA", value=date.today(), format="DD/MM/YYYY") + emitente = col_h2.text_input("EMITENTE") + rnc_cliente_numero = col_h3.text_input("RNC CLIENTE Nº (opcional)", placeholder="N/A") + + col_h4, col_h5 = st.columns([1, 1]) + cliente_emitente = col_h4.text_input("CLIENTE EMITENTE (opcional)", placeholder="N/A") + origem_form = col_h5.selectbox("Origem", ORIGENS_FORMS, index=2) # padrão: Outras + + st.markdown("##### Envolvidos") + col_e1, col_e2, col_e3 = st.columns([2, 1, 1]) + inv1_nome = col_e1.text_input("Envolvido 1 — Nome", placeholder="Ex.: Andreia Araújo") + inv1_matr = col_e2.text_input("Matrícula 1", placeholder="") + inv1_func = col_e3.text_input("Função 1", placeholder="Ex.: Operations Leader") + + col_e4, col_e5, col_e6 = st.columns([2, 1, 1]) + inv2_nome = col_e4.text_input("Envolvido 2 — Nome", placeholder="") + inv2_matr = col_e5.text_input("Matrícula 2", placeholder="") + inv2_func = col_e6.text_input("Função 2", placeholder="") + + col_h6, col_h7 = st.columns([1, 1]) + area_solicitante = col_h6.text_input("Área Solicitante", placeholder="Ex.: Operacional SBM") + area_notificada = col_h7.text_input("Área Notificada", placeholder="Ex.: QSMS") + + st.markdown("---") + st.markdown("#### Classificação e Contexto") + col_c1, col_c2, col_c3 = st.columns([1, 1, 1]) + tipo = col_c1.selectbox("Tipo", TIPOS, index=0) + severidade = col_c2.selectbox("Severidade", SEVERIDADES, index=1) + prioridade = col_c3.selectbox("Prioridade", PRIORIDADES, index=1) + + col_c4, col_c5 = st.columns([1, 1]) + cliente = col_c4.text_input("Cliente (opcional)") + local = col_c5.text_input("Local (opcional)") + + st.markdown("---") + st.markdown("#### Descrição da Não Conformidade") + descricao_nc = st.text_area("Descrição detalhada", height=160) + + st.markdown("---") + st.markdown("#### Ação Imediata/Contenção (linha avulsa / legado)") + ac_imediata_desc = st.text_area("Ação Imediata/Contenção (disposição quando aplicável)", height=120) + col_ai1, col_ai2 = st.columns([1, 1]) + ac_imediata_resp = col_ai1.text_input("Responsável pela Ação Imediata") + ac_imediata_data = col_ai2.date_input("Data de Conclusão (Ação Imediata)", value=None, format="DD/MM/YYYY") + + # >>> Linha avulsa (legado) + st.markdown("#### Ação Corretiva (linha avulsa / legado)") + ac_corretiva_desc = st.text_area("Ação Corretiva (quando aplicável)", height=120) + col_cx1, col_cx2 = st.columns([1, 1]) + ac_corretiva_resp = col_cx1.text_input("Responsável pela Ação Corretiva") + ac_corretiva_data = col_cx2.date_input("Data de Conclusão (Ação Corretiva)", value=None, format="DD/MM/YYYY") + + st.markdown("#### Ação Preventiva (linha avulsa / legado)") + ac_preventiva_desc = st.text_area("Ação Preventiva (quando aplicável)", height=120) + col_px1, col_px2 = st.columns([1, 1]) + ac_preventiva_resp = col_px1.text_input("Responsável pela Ação Preventiva") + ac_preventiva_data = col_px2.date_input("Data de Conclusão (Ação Preventiva)", value=None, format="DD/MM/YYYY") + + # >>> Múltiplas linhas + st.markdown("---") + st.markdown("#### Ações Imediatas/Contenção (múltiplas linhas)") + if "__ai_rows__" not in st.session_state: + st.session_state["__ai_rows__"] = {} + N = st.session_state.get("__qtd_ai__", 1) + for idx in range(N): + st.caption(f"— Ação imediata #{idx+1}") + col_m1, col_m2, col_m3 = st.columns([2, 1, 1]) + prev = st.session_state["__ai_rows__"].get(idx, {}) + desc_val = prev.get("desc", "") + resp_val = prev.get("resp", "") + date_val = prev.get("date", None) + desc_i = col_m1.text_input(f"Descrição da ação #{idx+1}", value=desc_val, key=f"ai_desc_{idx}") + resp_i = col_m2.text_input(f"Responsável #{idx+1}", value=resp_val, key=f"ai_resp_{idx}") + date_i = col_m3.date_input(f"Data conclusão #{idx+1}", value=date_val, format="DD/MM/YYYY", key=f"ai_date_{idx}") + st.session_state["__ai_rows__"][idx] = {"desc": desc_i, "resp": resp_i, "date": date_i} + + st.markdown("---") + st.markdown("#### Ações Corretivas (múltiplas linhas)") + if "__ac_rows__" not in st.session_state: + st.session_state["__ac_rows__"] = {} + NC = st.session_state.get("__qtd_ac__", 0) + for idx in range(NC): + st.caption(f"— Ação corretiva #{idx+1}") + col_c1, col_c2, col_c3 = st.columns([2, 1, 1]) + prevc = st.session_state["__ac_rows__"].get(idx, {}) + desc_val = prevc.get("desc", "") + resp_val = prevc.get("resp", "") + date_val = prevc.get("date", None) + desc_i = col_c1.text_input(f"Descrição da ação corretiva #{idx+1}", value=desc_val, key=f"ac_desc_{idx}") + resp_i = col_c2.text_input(f"Responsável (corretiva) #{idx+1}", value=resp_val, key=f"ac_resp_{idx}") + date_i = col_c3.date_input(f"Data conclusão (corretiva) #{idx+1}", value=date_val, format="DD/MM/YYYY", key=f"ac_date_{idx}") + st.session_state["__ac_rows__"][idx] = {"desc": desc_i, "resp": resp_i, "date": date_i} + + st.markdown("---") + st.markdown("#### Ações Preventivas (múltiplas linhas)") + if "__ap_rows__" not in st.session_state: + st.session_state["__ap_rows__"] = {} + NP = st.session_state.get("__qtd_ap__", 0) + for idx in range(NP): + st.caption(f"— Ação preventiva #{idx+1}") + col_p1, col_p2, col_p3 = st.columns([2, 1, 1]) + prevp = st.session_state["__ap_rows__"].get(idx, {}) + desc_val = prevp.get("desc", "") + resp_val = prevp.get("resp", "") + date_val = prevp.get("date", None) + desc_i = col_p1.text_input(f"Descrição da ação preventiva #{idx+1}", value=desc_val, key=f"ap_desc_{idx}") + resp_i = col_p2.text_input(f"Responsável (preventiva) #{idx+1}", value=resp_val, key=f"ap_resp_{idx}") + date_i = col_p3.date_input(f"Data conclusão (preventiva) #{idx+1}", value=date_val, format="DD/MM/YYYY", key=f"ap_date_{idx}") + st.session_state["__ap_rows__"][idx] = {"desc": desc_i, "resp": resp_i, "date": date_i} + + st.markdown("---") + st.markdown("#### Análise das Causas") + metodologia = st.selectbox( + "Metodologia utilizada", + ["Ishikawa (Diagrama de Causa e Efeito)", "5 Porquês", "FMEA", "Análise de Processo", "Outra"], + index=0 + ) + causa_raiz = st.text_area("Causa Raiz Identificada", height=120) + + with st.expander("Metodologia Ishikawa (opcional) — preencher 1ª, 2ª, 3ª, 4ª por categoria"): + st.caption("Preencha quando a metodologia for Ishikawa. Caso contrário, deixe em branco.") + def ishikawa_inputs(label_prefix): + col_i1, col_i2 = st.columns([1, 1]) + a1 = col_i1.text_input(f"{label_prefix} — 1ª") + a2 = col_i2.text_input(f"{label_prefix} — 2ª") + col_i3, col_i4 = st.columns([1, 1]) + a3 = col_i3.text_input(f"{label_prefix} — 3ª") + a4 = col_i4.text_input(f"{label_prefix} — 4ª") + return [a1, a2, a3, a4] + + pessoa = ishikawa_inputs("Mão de Obra - Pessoa") + material = ishikawa_inputs("Material") + medida = ishikawa_inputs("Medida") + meio_ambiente = ishikawa_inputs("Meio Ambiente") + maquina = ishikawa_inputs("Máquina ou Equipamento") + metodo = ishikawa_inputs("Método") + + anexos = st.file_uploader("Anexos (opcional)", type=None, accept_multiple_files=True) + + enviar = st.form_submit_button("Salvar RNC") + + if enviar: + if not emitente or not descricao_nc: + st.warning("Preencha ao menos **Emitente** e **Descrição da Não Conformidade**.") + return + + try: + codigo = gerar_codigo_rnc(db) + + # Cabeçalho estruturado (persistido em 'descricao' antes do texto da NC) + header_md = f"""**FOR-SGQ-08 • Rev 01** +**DATA:** {data_form.strftime('%d/%m/%Y')} • **EMITENTE:** {emitente} • **RNC Nº:** {codigo} +**RNC CLIENTE Nº:** {rnc_cliente_numero or 'N/A'} • **CLIENTE EMITENTE:** {cliente_emitente or 'N/A'} +**Área Solicitante:** {area_solicitante or '—'} • **Origem:** {origem_form} • **Área Notificada:** {area_notificada or '—'} +**Envolvidos:** +- {inv1_nome or '—'} • Matr.: {inv1_matr or '—'} • Função: {inv1_func or '—'} +- {inv2_nome or '—'} • Matr.: {inv2_matr or '—'} • Função: {inv2_func or '—'} + +**Descrição da Não Conformidade:** +{descricao_nc.strip()} +""" + + # Bloco de causa raiz / metodologia (persistido em 'causa_raiz') + causa_md = f"""**Metodologia utilizada:** {metodologia} +**Causa Raiz Identificada:** +{(causa_raiz or '').strip() or '—'} +""" + + def _format_cat(nome, vals): + filas = [v for v in (vals or []) if (v or "").strip()] + if not filas: + return "" + lines = "\n".join([f"- {i+1}ª: {filas[i]}" for i in range(len(filas))]) + return f"**{nome}:**\n{lines}\n" + + ish_md_parts = [] + ish_md_parts.append(_format_cat("Mão de Obra - Pessoa", pessoa)) + ish_md_parts.append(_format_cat("Material", material)) + ish_md_parts.append(_format_cat("Medida", medida)) + ish_md_parts.append(_format_cat("Meio Ambiente", meio_ambiente)) + ish_md_parts.append(_format_cat("Máquina ou Equipamento", maquina)) + ish_md_parts.append(_format_cat("Método", metodo)) + ish_md = "\n".join([p for p in ish_md_parts if p]) + + if ish_md.strip(): + causa_md += "\n**Ishikawa (Diagrama de Causa e Efeito):**\n" + ish_md + + # Cria RNC + rnc = RNC( + codigo=codigo, + titulo=f"RNC {codigo} • {emitente}", + descricao=header_md, # cabeçalho + descrição NC + origem=origem_form, + tipo=(tipo or "").strip() or None, + severidade=(severidade or "").strip() or None, + prioridade=(prioridade or "").strip() or None, + status="Aberta", + data_abertura=datetime.utcnow(), + prazo=None, + responsavel=None, # pode ser definido depois + area_solicitante=(area_solicitante or "").strip() or None, + area_notificada=(area_notificada or "").strip() or None, + criado_por=st.session_state.get("usuario") or "desconhecido", + cliente=(cliente or "").strip() or None, + local=(local or "").strip() or None, + causa_raiz=causa_md, # bloco estruturado + emitente=emitente, + data_form=data_form, + rnc_cliente_numero=(rnc_cliente_numero or "").strip() or None, + cliente_emitente=(cliente_emitente or "").strip() or None + ) + db.add(rnc) + db.commit() + + # Ação Imediata/Contenção (LEGADO — uma linha) + if (ac_imediata_desc or "").strip(): + try: + ac = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[IMEDIATA/CONTENÇÃO] {ac_imediata_desc.strip()}", + responsavel=(ac_imediata_resp or "").strip() or None, + prazo=datetime.combine(ac_imediata_data, datetime.min.time()) if isinstance(ac_imediata_data, date) else None, + status="Concluída" if isinstance(ac_imediata_data, date) else "Em execução", + conclusao_em=datetime.combine(ac_imediata_data, datetime.min.time()) if isinstance(ac_imediata_data, date) else None, + eficacia=None + ) + db.add(ac) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar a Ação Imediata (linha única): {e}") + + # Ação Corretiva (LEGADO — uma linha) + if (ac_corretiva_desc or "").strip(): + try: + acx = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[Corretiva] {ac_corretiva_desc.strip()}", + responsavel=(ac_corretiva_resp or "").strip() or None, + prazo=datetime.combine(ac_corretiva_data, datetime.min.time()) if isinstance(ac_corretiva_data, date) else None, + status="Concluída" if isinstance(ac_corretiva_data, date) else "Em execução", + conclusao_em=datetime.combine(ac_corretiva_data, datetime.min.time()) if isinstance(ac_corretiva_data, date) else None, + eficacia=None + ) + db.add(acx) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar a Ação Corretiva (linha única): {e}") + + # Ação Preventiva (LEGADO — uma linha) + if (ac_preventiva_desc or "").strip(): + try: + apx = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[Preventiva] {ac_preventiva_desc.strip()}", + responsavel=(ac_preventiva_resp or "").strip() or None, + prazo=datetime.combine(ac_preventiva_data, datetime.min.time()) if isinstance(ac_preventiva_data, date) else None, + status="Concluída" if isinstance(ac_preventiva_data, date) else "Em execução", + conclusao_em=datetime.combine(ac_preventiva_data, datetime.min.time()) if isinstance(ac_preventiva_data, date) else None, + eficacia=None + ) + db.add(apx) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar a Ação Preventiva (linha única): {e}") + + # salvar múltiplas Ações Imediatas/Contenção + try: + ac_rows = st.session_state.get("__ai_rows__", {}) + N = st.session_state.get("__qtd_ai__", 1) + if ac_rows: + for idx in range(N): + row = ac_rows.get(idx, {}) + desc = (row.get("desc") or "").strip() + resp = (row.get("resp") or "").strip() or None + dt = row.get("date", None) + if desc: + ac = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[IMEDIATA/CONTENÇÃO] {desc}", + responsavel=resp, + prazo=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + status="Concluída" if isinstance(dt, date) else "Em execução", + conclusao_em=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + eficacia=None + ) + db.add(ac) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar ações imediatas (múltiplas): {e}") + + # salvar múltiplas Ações CORRETIVAS + try: + ac_rows2 = st.session_state.get("__ac_rows__", {}) + NC = st.session_state.get("__qtd_ac__", 0) + if ac_rows2 and NC > 0: + for idx in range(NC): + row = ac_rows2.get(idx, {}) + desc = (row.get("desc") or "").strip() + resp = (row.get("resp") or "").strip() or None + dt = row.get("date", None) + if desc: + acor = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[Corretiva] {desc}", + responsavel=resp, + prazo=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + status="Concluída" if isinstance(dt, date) else "Em execução", + conclusao_em=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + eficacia=None + ) + db.add(acor) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar ações corretivas (múltiplas): {e}") + + # salvar múltiplas Ações PREVENTIVAS + try: + ap_rows2 = st.session_state.get("__ap_rows__", {}) + NP = st.session_state.get("__qtd_ap__", 0) + if ap_rows2 and NP > 0: + for idx in range(NP): + row = ap_rows2.get(idx, {}) + desc = (row.get("desc") or "").strip() + resp = (row.get("resp") or "").strip() or None + dt = row.get("date", None) + if desc: + apre = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"[Preventiva] {desc}", + responsavel=resp, + prazo=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + status="Concluída" if isinstance(dt, date) else "Em execução", + conclusao_em=datetime.combine(dt, datetime.min.time()) if isinstance(dt, date) else None, + eficacia=None + ) + db.add(apre) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar ações preventivas (múltiplas): {e}") + + # Anexos + if anexos: + dest_dir = os.path.join(UPLOAD_DIR, rnc.codigo) + _ensure_upload_dir(dest_dir) + for up in anexos: + dest_path = os.path.join(dest_dir, up.name) + with open(dest_path, "wb") as f: + f.write(up.getbuffer()) + anexo = RNCAnexo( + rnc_id=rnc.id, + nome_arquivo=up.name, + caminho=dest_path, + conteudo_tipo=getattr(up, "type", None), + enviado_por=st.session_state.get("usuario"), + enviado_em=datetime.utcnow() + ) + db.add(anexo) + db.commit() + + # Auditoria + _registrar_log(st.session_state.get("usuario"), f"Criou RNC {rnc.codigo}", "rnc", rnc.id) + + # Notificação (opcional) + emails = [] + criador_email = _get_user_email(rnc.criado_por) + if criador_email: + emails.append(criador_email) + assunto = f"[IOI-RUN] Nova RNC criada: {rnc.codigo}" + corpo = f"""Uma nova RNC foi criada. + +Código: {rnc.codigo} +Emitente: {emitente} +Origem: {origem_form} +Tipo: {rnc.tipo or '—'} | Severidade: {rnc.severidade or '—'} | Prioridade: {rnc.prioridade or '—'} +Área Solicitante: {area_solicitante or '—'} | Área Notificada: {area_notificada or '—'} +Cliente: {rnc.cliente or '—'} | Local: {rnc.local or '—'} +Abertura: {rnc.data_abertura.strftime('%d/%m/%Y %H:%M')} + +Descrição da NC: +{descricao_nc.strip()} +""" + _send_email_para_responsavel(assunto, corpo, emails) + + st.success(f"RNC **{rnc.codigo}** criada com sucesso!") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar a RNC: {e}") + + +# =============================== +# EDIÇÃO de RNC existente +# =============================== +def _extrair_descricao_nc(descricao_md: str) -> str: + """Tenta extrair o corpo após o marcador '**Descrição da Não Conformidade:**'.""" + if not descricao_md: + return "" + marker = "**Descrição da Não Conformidade:**" + if marker in descricao_md: + try: + return descricao_md.split(marker, 1)[1].strip() + except Exception: + return descricao_md + return descricao_md + + +def _montar_descricao_md(data_form: date, emitente: str, codigo: str, + rnc_cliente_numero: Optional[str], cliente_emitente: Optional[str], + area_solicitante: Optional[str], origem_form: str, area_notificada: Optional[str], + inv1: Dict[str, str], inv2: Dict[str, str], + descricao_nc: str) -> str: + header_md = f"""**FOR-SGQ-08 • Rev 01** +**DATA:** {data_form.strftime('%d/%m/%Y')} • **EMITENTE:** {emitente} • **RNC Nº:** {codigo} +**RNC CLIENTE Nº:** {rnc_cliente_numero or 'N/A'} • **CLIENTE EMITENTE:** {cliente_emitente or 'N/A'} +**Área Solicitante:** {area_solicitante or '—'} • **Origem:** {origem_form} • **Área Notificada:** {area_notificada or '—'} +**Envolvidos:** +- {inv1.get('nome') or '—'} • Matr.: {inv1.get('matr') or '—'} • Função: {inv1.get('func') or '—'} +- {inv2.get('nome') or '—'} • Matr.: {inv2.get('matr') or '—'} • Função: {inv2.get('func') or '—'} + +**Descrição da Não Conformidade:** +{(descricao_nc or '').strip()} +""" + return header_md + + +def _editar_rnc(db): + st.subheader("✏️ Editar RNC existente") + + # Controles de busca + col_s1, col_s2, col_s3, col_s4 = st.columns([1.2, 1.6, 0.6, 0.6]) + cod_informado = col_s1.text_input("Informe o código (ex.: RNC-2026-0001)", key="__rnc_edit_code__") + # últimas 30 + recentes = db.query(RNC).order_by(RNC.data_abertura.desc()).limit(30).all() + opts = [f"{r.codigo} — {r.titulo or '—'}" for r in recentes] + sel = col_s2.selectbox("ou selecione uma RNC recente", ["(nenhuma)"] + opts, index=0) + trigger = col_s3.button("Carregar") + limpar = col_s4.button("Limpar seleção") + + # Botão limpar: NÃO alterar __rnc_edit_code__ para evitar erro do Streamlit + if limpar: + st.session_state.pop("__rnc_edit_id__", None) + st.rerun() + + # Botão carregar + if trigger: + # Se "(nenhuma)" e sem código — limpar seleção anterior + if sel == "(nenhuma)" and not (cod_informado or "").strip(): + st.session_state.pop("__rnc_edit_id__", None) + st.info("Nenhuma RNC selecionada. A seleção foi limpa.") + st.rerun() + + # Resolve código digitado ou selecionado + codigo = (cod_informado or "").strip() or (sel.split(" — ")[0] if sel != "(nenhuma)" else "") + alvo = db.query(RNC).filter(RNC.codigo == codigo).first() if codigo else None + + if not alvo: + st.session_state.pop("__rnc_edit_id__", None) + st.warning("RNC não encontrada ou nenhuma selecionada. A seleção foi limpa.") + st.rerun() + else: + st.session_state["__rnc_edit_id__"] = alvo.id + st.success(f"RNC {alvo.codigo} carregada para edição.") + st.rerun() + + # RNC carregada + rnc = db.query(RNC).filter(RNC.id == st.session_state.get("__rnc_edit_id__")).first() if st.session_state.get("__rnc_edit_id__") else None + + if not rnc: + st.info("Carregue uma RNC para editar.") + return + + # Form de edição + with st.form(f"form_edit_rnc_{rnc.id}"): + st.markdown(f"**Editando:** {rnc.codigo} • {rnc.titulo or '—'}") + + col_eh1, col_eh2, col_eh3 = st.columns([1, 1, 1]) + data_form = col_eh1.date_input("DATA", value=rnc.data_form or date.today(), format="DD/MM/YYYY") + emitente = col_eh2.text_input("EMITENTE", value=rnc.emitente or "") + origem_form = col_eh3.selectbox("Origem", ORIGENS_FORMS, index=(ORIGENS_FORMS.index(rnc.origem) if rnc.origem in ORIGENS_FORMS else 2)) + + col_eh4, col_eh5 = st.columns([1, 1]) + rnc_cliente_numero = col_eh4.text_input("RNC CLIENTE Nº (opcional)", value=rnc.rnc_cliente_numero or "") + cliente_emitente = col_eh5.text_input("CLIENTE EMITENTE (opcional)", value=rnc.cliente_emitente or "") + + col_h6, col_h7 = st.columns([1, 1]) + area_solicitante = col_h6.text_input("Área Solicitante", value=rnc.area_solicitante or "") + area_notificada = col_h7.text_input("Área Notificada", value=rnc.area_notificada or "") + + st.markdown("##### Classificação") + col_c1, col_c2, col_c3 = st.columns([1, 1, 1]) + tipo = col_c1.selectbox("Tipo", TIPOS, index=(TIPOS.index(rnc.tipo) if rnc.tipo in TIPOS else 0)) + severidade = col_c2.selectbox("Severidade", SEVERIDADES, index=(SEVERIDADES.index(rnc.severidade) if rnc.severidade in SEVERIDADES else 1)) + prioridade = col_c3.selectbox("Prioridade", PRIORIDADES, index=(PRIORIDADES.index(rnc.prioridade) if rnc.prioridade in PRIORIDADES else 1)) + + col_c4, col_c5 = st.columns([1, 1]) + cliente = col_c4.text_input("Cliente (opcional)", value=rnc.cliente or "") + local = col_c5.text_input("Local (opcional)", value=rnc.local or "") + + st.markdown("##### Atribuição e Prazo") + col_as1, col_as2, col_as3 = st.columns([1, 1, 1]) + responsavel = col_as1.text_input("Responsável", value=rnc.responsavel or "") + prazo = col_as2.date_input("Prazo (conclusão)", value=(rnc.prazo.date() if isinstance(rnc.prazo, datetime) else rnc.prazo) if rnc.prazo else None, format="DD/MM/YYYY") + status = col_as3.selectbox("Status", STATUS_OPCOES, index=(STATUS_OPCOES.index(rnc.status) if rnc.status in STATUS_OPCOES else 0)) + + st.markdown("##### Descrição e Causa Raiz") + # tenta extrair apenas a descrição da NC a partir do markdown original + desc_nc_atual = _extrair_descricao_nc(rnc.descricao or "") + descricao_nc = st.text_area("Descrição da Não Conformidade (substituir corpo)", value=desc_nc_atual or "", height=140) + causa_raiz = st.text_area("Causa Raiz Identificada", value=rnc.causa_raiz or "", height=120) + + # Envolvidos (mantemos inputs simples; no cadastro ficam no cabeçalho) + with st.expander("Envolvidos (opcional)"): + col_en1, col_en2, col_en3 = st.columns([2, 1, 1]) + inv1_nome = col_en1.text_input("Envolvido 1 — Nome", value="") + inv1_matr = col_en2.text_input("Matrícula 1", value="") + inv1_func = col_en3.text_input("Função 1", value="") + + col_en4, col_en5, col_en6 = st.columns([2, 1, 1]) + inv2_nome = col_en4.text_input("Envolvido 2 — Nome", value="") + inv2_matr = col_en5.text_input("Matrícula 2", value="") + inv2_func = col_en6.text_input("Função 2", value="") + + salvar = st.form_submit_button("💾 Salvar alterações") + + if salvar: + usuario = st.session_state.get("usuario") + perfil = (st.session_state.get("perfil") or "user").lower() + + # Permissão para Encerrar/Cancelar + if status in ["Encerrada", "Cancelada"] and not can_close_or_cancel(perfil): + st.warning("Somente administradores podem **encerrar** ou **cancelar** RNC.") + return + + try: + # Atualiza campos básicos + rnc.emitente = (emitente or "").strip() or None + rnc.origem = origem_form + rnc.rnc_cliente_numero = (rnc_cliente_numero or "").strip() or None + rnc.cliente_emitente = (cliente_emitente or "").strip() or None + rnc.area_solicitante = (area_solicitante or "").strip() or None + rnc.area_notificada = (area_notificada or "").strip() or None + rnc.tipo = (tipo or "").strip() or None + rnc.severidade = (severidade or "").strip() or None + rnc.prioridade = (prioridade or "").strip() or None + rnc.cliente = (cliente or "").strip() or None + rnc.local = (local or "").strip() or None + rnc.responsavel = (responsavel or "").strip() or None + rnc.prazo = datetime.combine(prazo, datetime.min.time()) if isinstance(prazo, date) else None + rnc.status = status + rnc.data_form = data_form + rnc.titulo = f"RNC {rnc.codigo} • {emitente or '—'}" + rnc.causa_raiz = (causa_raiz or "").strip() or None + + # Reconstrói o markdown de 'descricao' no mesmo padrão do cadastro + inv1 = {"nome": inv1_nome, "matr": inv1_matr, "func": inv1_func} + inv2 = {"nome": inv2_nome, "matr": inv2_matr, "func": inv2_func} + rnc.descricao = _montar_descricao_md( + data_form=data_form, + emitente=emitente, + codigo=rnc.codigo, + rnc_cliente_numero=rnc_cliente_numero, + cliente_emitente=cliente_emitente, + area_solicitante=area_solicitante, + origem_form=origem_form, + area_notificada=area_notificada, + inv1=inv1, + inv2=inv2, + descricao_nc=descricao_nc, + ) + + # Encerramento: registra data + if status == "Encerrada": + rnc.encerrada_em = datetime.utcnow() + elif status != "Encerrada": + rnc.encerrada_em = None + + db.add(rnc) + db.commit() + + _registrar_log(usuario, f"Editou RNC {rnc.codigo}", "rnc", rnc.id) + st.success("RNC atualizada com sucesso!") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar alterações: {e}") + + +# =============================== +# Anexos / Plano de Ação / Timeline / Aprovação +# =============================== +def _mostrar_anexos(rnc: RNC, db): + st.markdown("**Anexos**") + if rnc.anexos: + for an in rnc.anexos: + st.caption(f"📎 {an.nome_arquivo} • {an.enviado_por or '—'} • {an.enviado_em.strftime('%d/%m/%Y %H:%M')}") + if os.path.exists(an.caminho): + try: + with open(an.caminho, "rb") as f: + st.download_button(f"Baixar {an.nome_arquivo}", f.read(), file_name=an.nome_arquivo, key=f"dl_{rnc.id}_{an.id}") + except Exception: + st.caption("Arquivo indisponível.") + else: + st.caption("Sem anexos") + + up_more = st.file_uploader(f"Adicionar anexos — {rnc.codigo}", accept_multiple_files=True, key=f"up_{rnc.id}") + if up_more: + dest_dir = os.path.join(UPLOAD_DIR, rnc.codigo) + _ensure_upload_dir(dest_dir) + try: + for up in up_more: + dest_path = os.path.join(dest_dir, up.name) + with open(dest_path, "wb") as f: + f.write(up.getbuffer()) + anexo = RNCAnexo( + rnc_id=rnc.id, + nome_arquivo=up.name, + caminho=dest_path, + conteudo_tipo=getattr(up, "type", None), + enviado_por=st.session_state.get("usuario"), + enviado_em=datetime.utcnow() + ) + db.add(anexo) + db.commit() + st.success("Anexos adicionados.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar anexos: {e}") + + +def _mostrar_plano_acao(rnc: RNC, db): + st.markdown("### 🛠️ Plano de Ação — Separado por categorias") + + def _cat(descricao: str) -> str: + s = (descricao or "").strip() + if s.startswith("[IMEDIATA/CONTENÇÃO]"): + return "imediata" + elif s.startswith("[Preventiva]"): + return "preventiva" + elif s.startswith("[Corretiva]"): + return "corretiva" + else: + return "outras" + + acoes = rnc.acoes or [] + acoes_imediatas = [a for a in acoes if _cat(a.descricao) == "imediata"] + acoes_corretivas = [a for a in acoes if _cat(a.descricao) == "corretiva"] + acoes_preventivas = [a for a in acoes if _cat(a.descricao) == "preventiva"] + acoes_outras = [a for a in acoes if _cat(a.descricao) == "outras"] + + def _render_bloco(titulo: str, lista: List[RNCAcaoCorretiva]): + st.markdown(f"#### {titulo}") + if not lista: + st.caption("— Nenhuma ação cadastrada nesta categoria.") + return + lista = sorted(lista, key=lambda a: (a.status, a.prazo or datetime.max)) + for ac in lista: + line = f"- {ac.descricao}" + line += f" • Resp: {ac.responsavel or '—'}" + line += f" • Prazo: {ac.prazo.strftime('%d/%m/%Y') if ac.prazo else '—'}" + line += f" • Status: {ac.status}" + if ac.eficacia: + line += f" • Eficácia: {ac.eficacia}" + st.write(line) + + with st.expander(f"Atualizar ação • ID {ac.id}", expanded=False): + col_s1, col_s2, col_s3 = st.columns(3) + novo_status = col_s1.selectbox( + "Status", + ["Planejada", "Em execução", "Concluída", "Ineficaz"], + index=["Planejada", "Em execução", "Concluída", "Ineficaz"].index(ac.status) + if ac.status in ["Planejada", "Em execução", "Concluída", "Ineficaz"] else 0, + key=f"ac_st_{ac.id}" + ) + nova_eficacia = col_s2.selectbox( + "Eficácia (resultado da ação)", + ["(não avaliada)", "Eficaz", "Parcial", "Ineficaz"], + index=0 if not ac.eficacia else ["(não avaliada)", "Eficaz", "Parcial", "Ineficaz"].index( + ac.eficacia if ac.eficacia in ["Eficaz", "Parcial", "Ineficaz"] else "(não avaliada)" + ), + key=f"ac_eff_{ac.id}" + ) + concluir = col_s3.button("Salvar atualização", key=f"ac_sv_{ac.id}") + + if concluir: + try: + ac.status = novo_status + if novo_status == "Concluída": + ac.conclusao_em = datetime.utcnow() + ac.eficacia = None if nova_eficacia == "(não avaliada)" else nova_eficacia + db.add(ac) + db.commit() + _registrar_log(st.session_state.get("usuario"), f"Atualizou ação RNC {rnc.codigo} (ID ação {ac.id})", "rnc_acao", ac.id) + st.success("Ação atualizada.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao atualizar ação: {e}") + + _render_bloco("🚀 Ações Imediatas/Contenção", acoes_imediatas) + _render_bloco("🔧 Ações Corretivas", acoes_corretivas) + _render_bloco("🛡️ Ações Preventivas", acoes_preventivas) + _render_bloco("📁 Outras (sem prefixo)", acoes_outras) + + # Form nova ação + with st.form(f"form_new_action_{rnc.id}"): + col_a1, col_a2, col_a3, col_a4 = st.columns([1.6, 1, 1, 1]) + ac_tipo = col_a1.selectbox("Tipo da ação", ["Imediata/Contenção", "Corretiva", "Preventiva"], index=1, key=f"ac_tipo_{rnc.id}") + ac_desc = col_a1.text_input("Descrição da ação", key=f"ac_desc_{rnc.id}") + ac_resp = col_a2.text_input("Responsável", key=f"ac_resp_{rnc.id}") + ac_prazo = col_a3.date_input("Data de Conclusão (prazo)", value=None, format="DD/MM/YYYY", key=f"ac_prazo_{rnc.id}") + ac_enviar = col_a4.form_submit_button("Adicionar ação") + if ac_enviar: + if not ac_desc: + st.warning("Informe a descrição da ação.") + else: + try: + tipo_prefix = "[IMEDIATA/CONTENÇÃO]" if ac_tipo == "Imediata/Contenção" else ("[Preventiva]" if ac_tipo == "Preventiva" else "[Corretiva]") + new_ac = RNCAcaoCorretiva( + rnc_id=rnc.id, + descricao=f"{tipo_prefix} {ac_desc.strip()}", + responsavel=(ac_resp or "").strip() or None, + prazo=datetime.combine(ac_prazo, datetime.min.time()) if isinstance(ac_prazo, date) else None, + status="Planejada" if not isinstance(ac_prazo, date) else "Concluída", + conclusao_em=datetime.combine(ac_prazo, datetime.min.time()) if isinstance(ac_prazo, date) else None + ) + db.add(new_ac) + db.commit() + _registrar_log(st.session_state.get("usuario"), f"Adicionou ação em {rnc.codigo}", "rnc_acao", new_ac.id) + st.success("Ação adicionada.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao salvar ação: {e}") + + +def _mostrar_verificacao_eficacia(rnc: RNC, db, usuario: str, perfil: str): + st.markdown("### ✅ Verificação da Eficácia e Aprovação") + st.caption("Descreva como a eficácia será monitorada e registre o resultado das ações. No caso de **Não Eficaz**, a RNC será reaberta.") + with st.form(f"form_verif_{rnc.id}"): + plano = st.text_area("O que será feito para manter e acompanhar a eficácia das ações aplicadas?", height=120, key=f"verif_plano_{rnc.id}") + col_v1, col_v2, col_v3 = st.columns([1, 1, 1]) + resultado = col_v1.selectbox("Resultado das ações", ["(pendente)", "Eficaz", "Não Eficaz"], index=0, key=f"verif_res_{rnc.id}") + encerrar_agora = col_v2.checkbox("Encerrar RNC (se Eficaz)", value=False, key=f"verif_close_{rnc.id}") + resp_enc = col_v3.text_input("Responsável pelo encerramento (opcional)", value=usuario, key=f"verif_enc_{rnc.id}") + enviar = st.form_submit_button("Registrar verificação") + if enviar: + if not plano.strip() and resultado == "(pendente)": + st.warning("Registre o plano de verificação ou o resultado.") + return + try: + msg = f"[Verificação da Eficácia]\n{plano.strip() or '(sem plano)'}\n\nResultado: {resultado}" + cm = RNCComentario( + rnc_id=rnc.id, + autor=usuario, + mensagem=msg, + data=datetime.utcnow() + ) + db.add(cm) + + # Regras: se Não Eficaz → reabrir (Em Análise). Se Eficaz e encerrar → Encerrada. + if resultado == "Não Eficaz": + rnc.status = "Em Análise" + rnc.encerrada_em = None + db.add(rnc) + elif resultado == "Eficaz" and encerrar_agora and can_close_or_cancel(perfil): + rnc.status = "Encerrada" + rnc.encerrada_em = datetime.utcnow() + cm2 = RNCComentario( + rnc_id=rnc.id, + autor=resp_enc or usuario, + mensagem=f"[Encerramento] RNC encerrada por {resp_enc or usuario} em {datetime.utcnow().strftime('%d/%m/%Y %H:%M')}", + status_novo="Encerrada", + data=datetime.utcnow() + ) + db.add(cm2) + db.add(rnc) + db.commit() + + _registrar_log(usuario, f"Verificação de eficácia em {rnc.codigo} (resultado: {resultado})", "rnc", rnc.id) + st.success("Verificação registrada.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao registrar verificação: {e}") + + +def _mostrar_timeline(rnc: RNC, db, usuario: str, perfil: str): + st.markdown("### 🕒 Timeline / Andamento") + if rnc.comentarios: + for cm in sorted(rnc.comentarios, key=lambda c: c.data, reverse=True): + line = f"**{cm.data.strftime('%d/%m/%Y %H:%M')}** • {cm.autor}: {cm.mensagem}" + changes = [] + if cm.status_novo: + changes.append(f"Status → {cm.status_novo}") + if cm.responsavel_novo: + changes.append(f"Responsável → {cm.responsavel_novo}") + if cm.prazo_novo: + changes.append(f"Prazo → {cm.prazo_novo.strftime('%d/%m/%Y')}") + if changes: + line += " \n" + " • ".join(changes) + st.write(line) + else: + st.caption("Sem comentários ainda.") + + st.markdown("#### Comentário/Atualização") + with st.form(f"form_comment_{rnc.id}"): + msg = st.text_area("Comentário/atualização (ex.: progresso, decisões)", height=80, key=f"cm_msg_{rnc.id}") + col_u1, col_u2, col_u3, col_u4 = st.columns(4) + novo_status = col_u1.selectbox("Atualizar status", ["(sem mudança)"] + STATUS_OPCOES, index=0, key=f"cm_st_{rnc.id}") + novo_resp = col_u2.text_input("Atualizar responsável", key=f"cm_resp_{rnc.id}") + novo_prazo = col_u3.date_input("Atualizar prazo", value=None, format="DD/MM/YYYY", key=f"cm_prazo_{rnc.id}") + salvar_cm = col_u4.form_submit_button("Registrar") + + if salvar_cm: + if not msg.strip() and novo_status == "(sem mudança)" and not novo_resp and not novo_prazo: + st.warning("Registre um comentário ou alguma mudança.") + return + + aplicar_mudancas = can_edit(rnc, usuario, perfil) + status_desejado = None if novo_status == "(sem mudança)" else novo_status + + if status_desejado in ["Encerrada", "Cancelada"] and not can_close_or_cancel(perfil): + st.warning("Somente administradores podem **encerrar** ou **cancelar** RNC.") + aplicar_mudancas = False # não aplicar status proibido + + try: + cm = RNCComentario( + rnc_id=rnc.id, + autor=usuario, + mensagem=((msg or "").strip() or "(sem mensagem)"), + status_novo=status_desejado, + responsavel_novo=(novo_resp or "").strip() or None, + prazo_novo=datetime.combine(novo_prazo, datetime.min.time()) if isinstance(novo_prazo, date) else None, + data=datetime.utcnow() + ) + db.add(cm) + + # Aplica mudanças + if aplicar_mudancas: + if status_desejado: + rnc.status = status_desejado + if status_desejado == "Encerrada": + rnc.encerrada_em = datetime.utcnow() + if cm.responsavel_novo: + rnc.responsavel = cm.responsavel_novo + if cm.prazo_novo: + rnc.prazo = cm.prazo_novo + db.add(rnc) + db.commit() + + _registrar_log(usuario, f"Atualizou RNC {rnc.codigo}", "rnc", rnc.id) + + # Notificação (opcional) + if status_desejado in ["Verificação", "Encerrada"]: + emails = [] + if rnc.responsavel: + resp_email = _get_user_email(rnc.responsavel) + if resp_email: + emails.append(resp_email) + criador_email = _get_user_email(rnc.criado_por) + if criador_email: + emails.append(criador_email) + assunto = f"[IOI-RUN] RNC {rnc.codigo} — Status: {status_desejado}" + corpo = f"""A RNC {rnc.codigo} teve seu status atualizado para: {status_desejado}. + +Título: {rnc.titulo} +Responsável: {rnc.responsavel or '—'} +Prazo: {rnc.prazo.strftime('%d/%m/%Y') if rnc.prazo else '—'} +Atualizado por: {usuario} +Data: {datetime.utcnow().strftime('%d/%m/%Y %H:%M')} + +Comentário: +{(msg or '').strip() or '(sem mensagem)'}""" + _send_email_para_responsavel(assunto, corpo, emails) + + st.success("Atualização registradoa.") + st.rerun() + except Exception as e: + db.rollback() + st.error(f"Erro ao registrar atualização: {e}") + + +def _mostrar_analise_aprovacao(rnc: RNC, db, usuario: str): + st.markdown("### 🧾 Análise Crítica e Aprovação") + st.caption("Registre os responsáveis pela análise crítica (QSMS) e aprovação (Diretoria).") + with st.form(f"form_aprov_{rnc.id}"): + st.markdown("**Análise Crítica (QSMS)**") + col_a1, col_a2 = st.columns([1, 1]) + analise_nome = col_a1.text_input("Nome (QSMS)", key=f"ap_nome_{rnc.id}") + analise_setor = col_a2.text_input("Setor (QSMS)", value="QSMS", key=f"ap_setor_{rnc.id}") + + st.markdown("**Aprovação (Diretoria)**") + col_b1, col_b2 = st.columns([1, 1]) + aprov_nome = col_b1.text_input("Nome (Diretoria)", key=f"ap_dir_nome_{rnc.id}") + aprov_setor = col_b2.text_input("Setor (Diretoria)", value="Diretor Executivo e de Operações", key=f"ap_dir_setor_{rnc.id}") + + enviar = st.form_submit_button("Registrar análise/aprovação") + if enviar: + try: + msg = f"[Análise Crítica / Aprovação]\nQSMS: {analise_nome or '—'} • Setor: {analise_setor or '—'}\nDiretoria: {aprov_nome or '—'} • Setor: {aprov_setor or '—'}" + cm = RNCComentario( + rnc_id=rnc.id, + autor=usuario, + mensagem=msg, + data=datetime.utcnow() + ) + db.add(cm) + db.commit() + except Exception as e: + db.rollback() + st.error(f"Erro ao registrar análise/aprovação: {e}") + + +# =============================== +# Tema/Background específico do módulo RNC +# =============================== +def _apply_rnc_theme(): + """ + Aplica um tema visual específico para a página do módulo RNC + (background diferenciado, header destacado e sutis ajustes visuais). + """ + st.markdown( + """ + + """, + unsafe_allow_html=True + ) + + +# =============================== +# Página principal (container) +# =============================== +def _card_rnc_header(rnc: RNC): + col_a, col_b = st.columns([3, 1]) + with col_a: + st.markdown(f"**{rnc.codigo}** — {rnc.titulo or '—'}") + st.caption( + f"Origem: {rnc.origem or '—'} • " + f"Tipo: {rnc.tipo or '—'} • " + f"Severidade: {rnc.severidade or '—'} • " + f"Prioridade: {rnc.prioridade or '—'}" + ) + st.caption( + f"Responsável: {rnc.responsavel or '—'} • " + f"Prazo: {rnc.prazo.strftime('%d/%m/%Y') if rnc.prazo else '—'} • " + f"Abertura: {rnc.data_abertura.strftime('%d/%m/%Y %H:%M') if rnc.data_abertura else '—'}" + ) + with col_b: + col_b.metric("Status", rnc.status or "—") + + +def _blocos_rnc(rnc: RNC, db, usuario: str, perfil: str): + st.markdown("---") + # Conteúdo principal do card/expander + st.markdown("#### 📄 Cabeçalho / Descrição") + st.markdown(rnc.descricao or "—") + + st.markdown("---") + _mostrar_anexos(rnc, db) + + st.markdown("---") + _mostrar_plano_acao(rnc, db) + + st.markdown("---") + _mostrar_timeline(rnc, db, usuario, perfil) + + st.markdown("---") + _mostrar_verificacao_eficacia(rnc, db, usuario, perfil) + + st.markdown("---") + _mostrar_analise_aprovacao(rnc, db, usuario) + + +def pagina(): + """Tela principal do módulo RNC (cadastro + edição).""" + _apply_rnc_theme() + + st.header("RNC • Registro de Não Conformidades (FOR-SGQ-08 Rev 01)") + + usuario = st.session_state.get("usuario") or "desconhecido" + perfil = (st.session_state.get("perfil") or "user").lower() + + db = SessionLocal() + try: + # KPIs + _kpis_area(db, usuario, perfil) + st.divider() + + # Formulário de Nova RNC + _form_nova_rnc(db) + st.divider() + + # ✏️ Edição de RNC existente (sem listagem/export aqui) + _editar_rnc(db) + + # Se houver RNC carregada para edição, renderiza blocos auxiliares + if st.session_state.get("__rnc_edit_id__"): + rnc = db.query(RNC).filter(RNC.id == st.session_state["__rnc_edit_id__"]).first() + if rnc: + with st.expander(f"📂 Detalhes • {rnc.codigo} — {rnc.titulo or '—'}", expanded=False): + _card_rnc_header(rnc) + _blocos_rnc(rnc, db, usuario, perfil) + + except Exception as e: + st.error(f"Erro ao montar a página de RNC: {e}") + finally: + try: + db.close() + except Exception: + pass + + +# Alias opcional para compatibilidade com app.py +def view(): + """Alias de pagina() para compatibilidade.""" + return pagina() + + + + + + +