# CLAUDE.md — Não Conformidades (Relatórios > Qualidade) Gestão contínua das ocorrências e ações do Sistema de Gestão da Qualidade (Sigsistem, ISO 9001) da De Paula Contadores. Evolui uma skill do Claude (`projects/Controladoria - Analise NCs/analise-ncs.skill`) que gerava um Excel de 7 abas sob demanda, para uma ferramenta persistente dentro do Portal: cada Ocorrência/Ação é upsertada por código a cada importação, com um status interno de tratativa da Qualidade que reabre sozinho quando algo muda. ## Origem — a skill "analise-ncs" `projects/Controladoria - Analise NCs/` guarda a fonte de verdade das regras de negócio originais: - `analise-ncs_SKILL.md` — spec completa (conceitos, regras de classificação de cada aba do relatório antigo, formatação). - `analise-ncs.skill` (zip) → `scripts/gerar_relatorio.py` — script de referência (pandas), `scripts/colunas_xlsx.txt` — as 36 colunas do `.xlsx` de ocorrências. - `ocorrencias_pa_27082026.xlsx`/`acoes_27082026.xls` — amostras reais usadas para validar o parser novo contra o relatório de referência (`Relatorio_Qualidade_20260827.xlsx`) durante o desenvolvimento. Todo o parsing deste pacote foi portado (sem pandas, ver "Por que sem pandas" abaixo) linha a linha da lógica desse script, validado batendo os mesmos totais do relatório de referência (ocorrências, sem análise, análise sem ação, NCs sem ação corretiva, ações vencidas) — **exceto** "Total de Ações Abertas", que a skill original contava incluindo linhas de ocorrência sem nenhuma ação de fato aberta (artefato do script — `todas = v.copy()` sem filtrar por `Código da Ação` presente); esta ferramenta conta só ações reais. ## O que MUDA em relação à skill original 1. **Persistência contínua**: `NCOcorrencia`/`NCAcao` (models em `portal_api/models.py`) são upsertadas por código a cada importação, não descartadas depois de gerar um arquivo. 2. **Histórico completo de acompanhamentos**: `NCAcompanhamento` guarda TODAS as entradas de "Relato(s) do Acompanhamento" parseadas do `.xls`, não só a última (que é tudo que a skill original guardava). 3. **Status interno de tratativa da Qualidade** (`status_tratativa`, `pendente`/`tratado`) — não existe no Sigsistem. Fica `tratado` quando a Qualidade marca manualmente (`marcar-tratado`); **reabre sozinho** (`reabertura automática`) se uma importação nova trouxer algo diferente do que existia no momento do tratamento — ver `diff.py`. ## Pacote `portal_api/nao_conformidades/` (Python puro, sem ORM) | Arquivo | Conteúdo | |---|---| | `modelos.py` | Dataclasses `OcorrenciaExtraida`/`AcaoExtraida`/`AcompanhamentoExtraido`/`ResultadoProcessamento` — espelham os models Django campo a campo, sem nenhuma dependência de banco. | | `leiaute_ocorrencias.py` | Lê o `.xlsx` de ocorrências via `openpyxl` (sem pandas). `COLUNAS_ESPERADAS` lista as 36 colunas — se alguma faltar, vira aviso (não erro), pra tolerar pequenas mudanças de leiaute do Sigsistem. Filtra linhas com `Código da Ocorrência` não-numérico (lixo de fim de export). Detecta "Análise sem Ação" por LINHA (`tem_linha_analise_sem_acao`), não pelo agregado da ocorrência — uma ocorrência pode ter uma linha sem ação com análise preenchida mesmo já tendo outras ações abertas noutra linha. | | `leiaute_acoes.py` | Lê o `.xls` de ações — **é HTML, não Excel** (`encoding="latin-1"`, nunca abrir com `openpyxl`/leitor de planilha). Parseia por regex os blocos "Ocorrência N / Ação M" e a seção "Relato(s) do Acompanhamento" de cada um. `_separa_autor_texto()` é uma heurística (assume "DD/MM/AAAA - Nome - texto", nome ≤80 chars) — se o padrão não bater, `autor` fica vazio e `texto` guarda tudo, nunca perde informação. | | `classificacao.py` | `vencimento_efetivo()` (Prazo Prorrogado senão Data da Conclusão da Ação — só muda com importação nova, seguro cachear), `categoria_acao()` (Correção/Ação Corretiva/Outros), `status_prazo()`/`dias_para_vencer()` (**nunca persistidos** — dependem de "hoje", recalculados a cada leitura). | | `diff.py` | O núcleo da reabertura automática — ver seção própria abaixo. | | `pipeline.py` | `processa_importacao()` — só parsing/junção dos dois arquivos, não toca no banco. O upsert propriamente dito mora na view (`_aplica_upsert_nao_conformidades` em `portal_api/views.py`), mesma separação de responsabilidade de `planos_saude`/`indicadores`. | ### Por que sem pandas `planos_saude`/`indicadores` evitam pandas deliberadamente (parsing manual com `openpyxl`/`re`), apesar de `pandas` estar tecnicamente instalado no venv (dependência transitiva, provavelmente do `docling`). Este pacote segue o mesmo padrão — o volume real (amostra validada: 236 ocorrências, 290 ações, 929 entradas de acompanhamento) não justifica a complexidade de introduzir `pandas` como dependência direta de um pacote novo. ### Reabertura automática (`diff.py`) Cada `NCOcorrencia`/`NCAcao` tem `snapshot_tratativa` (`JSONField`) — o estado relevante congelado no momento em que foi marcada como `tratado` (`snapshot_ocorrencia()`/`snapshot_acao()`). A cada importação nova, se o item já estava `tratado`, `decide_reabertura_ocorrencia()`/`decide_reabertura_acao()` comparam o snapshot congelado contra o estado atual recém-extraído: - **Ação**: reabre se o **último acompanhamento tem data mais recente** que a congelada (comparação numérica de data, não de texto — robusto contra reformatação cosmética do export), ou se `situacao`/`fase`/`prazo_prorrogado` mudaram no Sigsistem. - **Ocorrência**: reabre se uma análise foi preenchida pela primeira vez, se o texto da análise mudou (hash de texto normalizado — espaços/capitalização não geram falso positivo, mas uma reformatação mais profunda do mesmo conteúdo pode gerar; risco residual documentado, não eliminado), ou se uma ação nova foi aberta. Quando reabre, `reaberto_em`/`reaberto_motivo` são preenchidos com o motivo textual (exibido na tela como selo "Reaberto" com tooltip) e `snapshot_tratativa` é limpo — o item volta a `pendente` até a Qualidade tratar de novo. A tratativa de fato (prorrogar/finalizar uma ação, decidir sobre uma análise) **acontece no Sigsistem**, sistema externo sem integração automática — o Portal só monitora e sinaliza; não há nenhuma escrita de volta pro Sigsistem. ### Upsert (`_aplica_upsert_nao_conformidades`, em `portal_api/views.py`) Fica na view, não no pacote puro (mesma separação de `planos_saude`/`indicadores`) — é aqui que a checagem de reabertura roda (só quando o item já estava `tratado`), porque só a view sabe o que já está persistido. Roda síncrono dentro de `transaction.atomic()`, mesmo padrão de `ImportacaoPlanoSaudeViewSet.create()`/`IndicadorApuracaoViewSet.create()`. **Em lote (`bulk_create`/`bulk_update`), não um `.save()`/`update_or_create`/`get_or_create` por item** — decisão corrigida numa rodada real de produção: a v1 fazia ~2 idas ao banco por item (ocorrência, ação, acompanhamento), o que parecia aceitável nos ~300 registros da amostra inicial mas quebrou assim que um export real maior apareceu (~1700 ocorrências/~3400 ações/~9000 acompanhamentos — ~28 mil idas ao banco, ~60s). Isso não dava só lentidão: em produção (gunicorn atrás de nginx, timeout padrão de worker de 30s), uma requisição de 60s é **derrubada no meio pelo próprio servidor de aplicação**, e o navegador só vê uma resposta genérica de erro — sem nenhum problema real de dado por trás. Reescrito pra pré-carregar o que já existe (poucos `SELECT ... WHERE codigo IN (...)`) e aplicar tudo de uma vez (`bulk_create`/`bulk_update` por model, com `batch_size=500` pra não estourar o limite de parâmetros de uma query só do Postgres) — ~8 idas ao banco no total, independente do tamanho do arquivo. **Pula registros que não mudaram nada desde a última importação** (compara os campos extraídos contra os já salvos antes de decidir se entra no lote de `bulk_update`) — importante porque reimportações periódicas trazem de volta o **histórico inteiro** do Sigsistem, não só o que é novo; sem esse pulo, cada reimportação reescreveria milhares de linhas idênticas à toa. Efeito colateral proposital: `resumo["ocorrencias_atualizadas"]`/`["acoes_atualizadas"]` agora significam "teve algum campo alterado", não "já existia e foi vista de novo" — uma reimportação do mesmo arquivo sem nada de novo mostra `0` em ambos (validado: reimportar o mesmo arquivo real caiu de 21s pra ~4s, quase todo esse tempo sendo upload+parsing, não banco). A checagem de reabertura roda de qualquer forma pra todo item já `tratado`, mudando campo ou não — é comparação de dict/hash em memória, não é o gargalo. `NCAcompanhamento` continua com `bulk_create(..., ignore_conflicts=True)` — o dedupe por `hash_entrada` é pré-calculado em memória (um `SELECT` só de `(acao_id, hash_entrada)` já existentes), `ignore_conflicts` é só rede de segurança pra duplicata dentro do próprio arquivo importado. ## Models (`portal_api/models.py`) - **`NaoConformidadeImportacao`** — log de auditoria de cada upload (`arquivo_ocorrencias`/`arquivo_acoes`, `resumo` JSONField com contadores, `avisos`). Diferente de `ImportacaoPlanoSaude`, **não é "dona"** das ocorrências/ações — excluir uma importação (`DELETE`) remove só o log e os 2 arquivos de `MEDIA_ROOT`, nunca `NCOcorrencia`/`NCAcao` (entidades contínuas, upsertadas por código, sobrevivem à importação que as trouxe). - **`NCOcorrencia`** — upsert por `codigo` (único). Campos espelham as colunas do export + `status_tratativa`/`snapshot_tratativa`/`tratado_*`/`reaberto_*` (controle interno) + `analise_sem_acao_detectada` (persistido do parsing, não re-derivável de forma confiável só a partir de `descricao_analise`+`acoes.count()` porque uma ocorrência pode ter linhas mistas). - **`NCAcao`** — FK `NCOcorrencia`, upsert por `(ocorrencia, codigo)`. `vencimento_efetivo`/`ultimo_acompanhamento_em`/`ultimo_acompanhamento_eh_prorrogacao` são cache seguro (só mudam com importação nova); `status_tratativa`/`snapshot_tratativa`/`tratado_*`/`reaberto_*` iguais à ocorrência. - **`NCAcompanhamento`** — FK `NCAcao`, append-only (`UniqueConstraint(acao, hash_entrada)` deduplica reimportações, nunca apaga uma entrada já persistida). `assunto` é `TextField`, não `CharField` — export real trouxe valores com mais de 390 caracteres (texto livre do Sigsistem, sem limite aparente). ## API (`portal_api/views.py`/`urls.py`) Todos os endpoints sob `PermissaoApp("relatorios", "nao-conformidades")` (toggle único). | Endpoint | Método | Uso | |---|---|---| | `/api/nao-conformidades/importacoes/` | GET/POST | histórico + nova importação (multipart, 2 arquivos); roda o pipeline síncrono + upsert, devolve o `resumo` | | `/api/nao-conformidades/importacoes/{id}/` | DELETE | remove só o log + os 2 arquivos | | `/api/nao-conformidades/ocorrencias/` | GET | filtros `?sem_analise=true`, `?analise_sem_acao=true`, `?status_tratativa=`, `?search=` | | `/api/nao-conformidades/ocorrencias/{id}/marcar-tratado/`, `/reabrir/` | POST | congela snapshot / reabre manualmente | | `/api/nao-conformidades/acoes/` | GET | filtros `?status_tratativa=`, `?status_prazo=` (`vencida`/`vence_7_dias`/`vence_30_dias`/`no_prazo`/`sem_vencimento`, replica `classificacao.status_prazo()` como filtro de data via `_filtra_status_prazo`), `?search=`; inclui `status_prazo`/`status_prazo_label`/`dias_para_vencer`/`ocorrencia_area`/`ocorrencia_clientes` calculados no serializer (nunca persistidos) | | `/api/nao-conformidades/acoes/{id}/` | GET | detalhe com `acompanhamentos` completo (histórico) | | `/api/nao-conformidades/acoes/{id}/marcar-tratado/`, `/reabrir/` | POST | idem ocorrência | | `/api/nao-conformidades/dashboard/` | GET | `?meses=` (default 6) ou `?data_inicio=&data_fim=`; indicadores "abertos agora" (nunca filtrados por período) + `tempos_medios` (4 KPIs + quebra por categoria, sobre todo o histórico, nunca filtrados por período — ver "Tempos médios" abaixo) + rankings de motivo (`tipos_causa`)/cliente (`clientes_relacionados`)/colaborador (`indicado_analise`) filtrados pelo período — clientes/colaboradores vêm quebrados por tipo (`{nome, nc, reclamacao, outros, total}`) | ### Tempos médios (`nao_conformidades_dashboard_view`, `tempos_medios`) 4 cards (`.ncf-kpi`, mesmo componente visual desde a rodada 89 — decisão explícita do usuário de manter o card simples em vez de um funil visual com setas, que chegou a ser implementado e foi revertido na mesma rodada): preenchimento da análise, abertura da ação, execução da ação (abertura → finalização) e ciclo completo (emissão da ocorrência → finalização da ação) — não é a soma dos 3 anteriores, porque cada etapa tem sua própria amostra (`n`) e nem toda ocorrência percorre todas as etapas até aqui. A etapa de execução (`execucao_acao_dias`/`n`) só existe desde que a planilha de ocorrências passou a trazer ações **já finalizadas** (`Data de Finalização`/`Dias para Finalização`, colunas que a exportação padrão do Sigsistem não preenche — só um export "com finalizadas" as traz, ver histórico do pacote); antes disso só os 2 primeiros KPIs tinham dado. - Cada média vem acompanhada do **tamanho da amostra** (`*_n`) — sem isso um número como "62 dias" parece mais sólido do que é, já que a amostra de quem já finalizou é normalmente bem menor que o total de ocorrências. - `execucao_acao_dias`/`n` é calculado só sobre `NCAcao` com `data_finalizacao` **e** `data_emissao` preenchidas (`Avg(data_finalizacao - data_emissao)` — confirmado que bate exatamente com o `Dias para Finalização` que o próprio Sigsistem calcula, então não precisa usar esse campo cru, só as datas). **Não filtra por `situacao`** ("Eficaz"/"Ineficaz") — os dois indicam que a ação foi concluída e avaliada, então os dois contam pro tempo de execução. - **Quebrado por categoria** (`execucao_por_categoria`, via `classificacao.categoria_acao()`) porque a média geral esconde uma variância enorme — na amostra real (import "com finalizadas", 3187 ações), de 1 a quase 800 dias, média 62. Correção/Ação Corretiva/Outros têm perfis de duração bem diferentes; misturar tudo numa média só seria enganoso. - Cálculo em Python (`views.py`, não uma `annotate` agrupada por categoria no ORM), porque `categoria_acao()` é uma heurística de texto (`"corretiva" in tipo.lower()` etc.) que não dá pra expressar como `CASE`/`Q` sem duplicar a lógica — o volume real (milhares de ações, não milhões) não justifica a complexidade de fazer isso no banco. ## Frontend (`templates/nao-conformidades.html`, `static/{css,js}/nao-conformidades.js`) Uma página com 3 abas (`.pa-tabs`, reaproveitado de `perfis-acesso.css`): **Dashboard** (default — cards, KPIs de tempo, seletor de período em chips, 3 rankings), **Gestão** (Sem Análise/Análise sem Ação/Ações) e **Importar** (histórico + upload). Gestão/Importar carregam sob demanda (lazy, só na primeira vez que a aba é aberta ou via drill-down do Dashboard). - **Drill-down Dashboard → Gestão**: os 4 cards com lista equivalente ("Ações vencidas", "Vence em ≤7 dias", "Ocorrências sem análise", "Análises sem ação") e as linhas dos 3 rankings são clicáveis — trocam pra Gestão já filtrada/rolada até a seção certa. Ver `irParaAcoesComPrazo`/`irParaSecaoGestao`/`irParaGestaoComBusca` em `nao-conformidades.js`. **Cuidado com corrida**: `ativarTab()` é **só UI** (nunca dispara carregamento) de propósito — um bug real já aconteceu aqui quando ela tinha esse efeito colateral e corria em paralelo com o carregamento explícito do drill-down, e a chamada que terminasse por último vencia (às vezes sobrescrevendo o filtro certo). Qualquer novo caminho que leve à Gestão deve sempre `await` o carregamento, nunca disparar dois carregamentos concorrentes da mesma lista. - **Filtro combinável de Ações**: duas fileiras de chips independentes, Prazo (`?status_prazo=`) e Tratativa (`?status_tratativa=`), combinam com AND (dois `