21 KiB
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.xlsxde 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
- Persistência contínua:
NCOcorrencia/NCAcao(models emportal_api/models.py) são upsertadas por código a cada importação, não descartadas depois de gerar um arquivo. - Histórico completo de acompanhamentos:
NCAcompanhamentoguarda TODAS as entradas de "Relato(s) do Acompanhamento" parseadas do.xls, não só a última (que é tudo que a skill original guardava). - Status interno de tratativa da Qualidade (
status_tratativa,pendente/tratado) — não existe no Sigsistem. Ficatratadoquando 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 — verdiff.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_prorrogadomudaram 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,resumoJSONField com contadores,avisos). Diferente deImportacaoPlanoSaude, não é "dona" das ocorrências/ações — excluir uma importação (DELETE) remove só o log e os 2 arquivos deMEDIA_ROOT, nuncaNCOcorrencia/NCAcao(entidades contínuas, upsertadas por código, sobrevivem à importação que as trouxe).NCOcorrencia— upsert porcodigo(ú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 dedescricao_analise+acoes.count()porque uma ocorrência pode ter linhas mistas).NCAcao— FKNCOcorrencia, upsert por(ocorrencia, codigo).vencimento_efetivo/ultimo_acompanhamento_em/ultimo_acompanhamento_eh_prorrogacaosão cache seguro (só mudam com importação nova);status_tratativa/snapshot_tratativa/tratado_*/reaberto_*iguais à ocorrência.NCAcompanhamento— FKNCAcao, 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=<bucket> (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ó sobreNCAcaocomdata_finalizacaoedata_emissaopreenchidas (Avg(data_finalizacao - data_emissao)— confirmado que bate exatamente com oDias para Finalizaçãoque o próprio Sigsistem calcula, então não precisa usar esse campo cru, só as datas). Não filtra porsituacao("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, viaclassificacao.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 umaannotateagrupada por categoria no ORM), porquecategoria_acao()é uma heurística de texto ("corretiva" in tipo.lower()etc.) que não dá pra expressar comoCASE/Qsem 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/irParaGestaoComBuscaemnao-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 sempreawaito 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<select>de fato seriam mais familiares, mas o padrão chip já é o mesmo do período do Dashboard). A tabela mostra as duas dimensões em colunas separadas ("Prazo" e "Tratativa") — nunca juntar num "Status" só, é exatamente essa mistura que gerou confusão antes. - Expandir linha (
pidNcfCriarLinhaExpandivel, mesmo padrão de.cc-painel-fiscal__toggledecusto-contratacao.css/.js— chevron que gira via[aria-expanded="true"] svg{transform:rotate(90deg)}) é o único mecanismo de "ver mais" das 3 listas: ocorrência (relato completo, causa, cliente, riscos etc. — parte já vem da própria listagem, o resto é buscado lazy emGET /ocorrencias/{id}/) e ação (texto completo + histórico de acompanhamentos, mais recente primeiro, 3 visíveis + "Mostrar mais", buscado lazy emGET /acoes/{id}/). Não usar modal pra isso — o modal de histórico existiu numa rodada anterior e foi removido a pedido do usuário (ficava colado no botão "Marcar como tratado"). - Busca compartilhada da Gestão (
#ncf-gestao-search): filtra as 3 listas ao mesmo tempo, sempre client-side sobre os dados já carregados (sem round-trip) — colaborador/responsável/emissor/cliente/assunto. - Botão "Copiar" do Resumo de Gestão (
resumoCopiarBtn):navigator.clipboard.writeText()só funciona em contexto seguro (HTTPS/localhost) — como a produção deste Portal roda em HTTP puro (sem TLS, decisão de infraestrutura fora do escopo desta aplicação),pidNcfCopiarTexto()cai num fallback comdocument.execCommand("copy")(via um<textarea>temporário fora da tela) sempre quenavigator.clipboard/window.isSecureContextnão estiverem disponíveis. Único uso de Clipboard API no Portal hoje — se o ambiente ganhar HTTPS no futuro, o fallback deixa de ser exercitado sozinho (a checagem deisSecureContextjá prioriza a API nativa), não precisa remover nada.
Reaproveita .status-pill (definido em perfis-acesso.css) com modificadores novos (--ok/--danger/--warning/--neutral, em nao-conformidades.css) em vez de inventar um componente de badge próprio. Chips de filtro/período (.ncf-chip/.ncf-chip-row) duplicam o visual de .ind-departamento-chip (indicador-desempenho.css) porque essa folha não é carregada nesta página.
Cuidado com especificidade CSS: .pa-table td:last-child{text-align:right} (de perfis-acesso.css) pega qualquer <td> que seja a única célula de uma linha — inclusive a célula do painel de detalhe (que usa colspan, então é sempre "a última"). Um bug real de alinhamento já aconteceu por isso; a correção foi dar à célula do painel uma classe própria com especificidade maior (.pa-table td.ncf-detail-cell), não confiar em .ncf-detail-row td (mesma especificidade da regra original, perderia).
Permissão e menu
catalogo.py: subgrupo qualidade dentro de MODULE_APPS["relatorios"], com uma única tool nao-conformidades — mesmo formato de subgrupo com toggle único já usado em Auditorias (consultoria-tributaria → controle-simples-nacional). Restrito por padrão: como relatorios está em SECTORAL_KEYS (herdaria True automático pra todo perfil setorial), seed_portal.py força permissoes["relatorios"]["apps"]["nao-conformidades"] = False pra todo perfil que não seja "Integração e Inovação" (código 8) — liberar outros perfis é manual, pela tela de Perfis de Acesso.
Riscos conhecidos
- Falso positivo na reabertura de ocorrência por texto: reformatação profunda (não cosmética) do mesmo conteúdo de análise pelo Sigsistem pode gerar hash diferente e reabrir sem necessidade real. Não validado contra múltiplos exports reais consecutivos da mesma ocorrência tratada — se acontecer na prática, considerar comparação mais tolerante (ex.: similaridade de texto) em vez de hash exato.
NCOcorrencia.codigo/NCAcao.codigocomoPositiveIntegerField: assumido do comportamento do parser de referência, validado contra dois exports reais (236 e depois 1709 ocorrências, 290 e depois 3401 ações) sem nenhum código não-numérico encontrado. Se um export futuro trouxer um código assim, a linha é descartada silenciosamente (mesmo comportamento de "linha inválida" da skill original) — considerarCharFieldse isso acontecer.- Upload ainda é síncrono (processa dentro da própria requisição, sem fila) — aceitável mesmo no volume real validado (~1700 ocorrências/~3400 ações/~9000 acompanhamentos processa em poucos segundos, ver "Upsert" acima) depois da otimização pra lote. Se o Sigsistem chegar a exportar uma ordem de grandeza acima disso, o gargalo passa a ser o parsing em si (
leiaute_ocorrencias/leiaute_acoes, ainda um loop por linha em Python puro) — reavaliar fila assíncrona nesse ponto (nenhum outro pacote do Portal usa hoje). Data de Finalização/Dias para Finalizaçãoseguem 100% em branco mesmo no export maior "com finalizadas" (confirmado numa rodada real) — o nome do arquivo sugere ações finalizadas, mas o Sigsistem aparentemente não preenche esses dois campos em nenhum cenário observado até agora. Se isso mudar, revisitar o KPI de "tempo médio de resolução" descartado no redesign do Dashboard (rodada 89).