Correçoes NCs som Finalizadas e Upload Arquivos

This commit is contained in:
Gabriel 2026-08-31 11:29:37 -03:00
parent 0886141ae3
commit 20de8691d4
19 changed files with 617479 additions and 83 deletions

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -461,3 +461,7 @@ manter o card grid original (agora com 4 cards em vez de 2, cada um com o
`n` da amostra). Detalhe completo em
`portal_api/nao_conformidades/CHANGELOG.md` (rodada 90) e
`portal_api/nao_conformidades/CLAUDE.md` (seção "Tempos médios").
### 91. Não Conformidades — upload travando com arquivo real grande (upsert virou lote)
Usuário reportou "Erro ao processar a solicitação." ao reprocessar os mesmos dois arquivos "com finalizadas" da rodada 90, e perguntou se seria a extensão `.xls`/`.xlsx` ou incompatibilidade com o ambiente Linux de produção. Nenhum dos dois: reproduzido localmente com os arquivos reais, o upload funcionava, só levava ~60s — tempo o bastante pra estourar o timeout de um worker WSGI em produção (gunicorn, 30s por padrão) ou disparar o autoreload do `manage.py runserver` em dev, derrubando a conexão sem nenhum problema de dado por trás. Causa raiz: o upsert (rodada 88) fazia uma ida ao banco por item (`update_or_create`/`get_or_create`), aceitável nos ~300 registros da amostra inicial mas não nos ~1700 ocorrências/~3400 ações/~9000 acompanhamentos do export real (quase 28 mil idas ao banco). Reescrito pra lote (`bulk_create`/`bulk_update`) e pra pular quem não mudou nada desde a última importação (reimportação periódica traz de volta o histórico inteiro, não só o novo) — reimportar o mesmo arquivo sem mudança real caiu de ~60s pra ~4s. Nenhuma migração, nenhuma mudança de contrato de API. Detalhe completo em `portal_api/nao_conformidades/CHANGELOG.md` (rodada 91) e `portal_api/nao_conformidades/CLAUDE.md` (seção "Upsert").

View File

@ -54,3 +54,11 @@ Antes de implementar, conferido que o `Dias para Finalização` calculado pelo S
- `tempos_medios` do dashboard ganhou uma 3ª etapa (`execucao_acao_dias`/`n`, abertura da ação → finalização, só sobre `NCAcao` com `data_finalizacao` preenchida — sem filtrar por `situacao`, já que "Eficaz" e "Ineficaz" indicam igualmente que a ação foi concluída), um KPI de ciclo completo (`ciclo_completo_dias`/`n`, emissão da ocorrência → finalização da ação) e uma quebra por categoria (`execucao_por_categoria`, via `classificacao.categoria_acao()`, calculada em Python porque é uma heurística de texto). As duas etapas que já existiam (preenchimento da análise, abertura da ação) ganharam `n` junto da média, pra deixar claro o tamanho da amostra por trás de cada número.
- Frontend: primeira versão trocou os cards por um funil visual (`Emissão → Análise → Ação aberta → Ação finalizada`, setas com a média de dias/`n` entre etapas) — **revertido no mesmo dia a pedido do usuário** ("os tempos médios, deixe como estava anteriormente, só inclua as informações do tempo para conclusão"): `.ncf-kpis`/`.ncf-kpi` continuam o card grid original de 2 cards, agora com 4 (análise, abertura, execução, ciclo completo), cada um mostrando `n` como uma segunda linha pequena abaixo do valor. A quebra por categoria (`execucao_por_categoria`) continua como tabelinha (`.pa-table`) abaixo dos cards.
- Ver `portal_api/nao_conformidades/CLAUDE.md`, seção "Tempos médios", pro detalhamento técnico.
### 91. Upload travando com "Erro ao processar a solicitação." — upsert virou lote
Usuário reportou erro genérico ao tentar reprocessar os mesmos dois arquivos "com finalizadas" da rodada 90 (`ocorrencias_pa_28082026-01 - COM FINALZIADAS.xlsx` + `acoes_28082026-01 - com finalizadas.xls`) e perguntou se seria a extensão `.xls`/`.xlsx` ou uma incompatibilidade com o ambiente Linux de produção. Nenhum dos dois: reproduzido localmente com os arquivos reais, o upload **funcionava**, só que levava ~60s — tempo o bastante pra estourar o timeout de um worker WSGI (gunicorn, 30s por padrão) em produção, ou disparar o autoreload do `manage.py runserver` em dev, derrubando a conexão no meio sem nenhum erro de dado por trás.
Causa raiz: `_aplica_upsert_nao_conformidades` (rodada 88) fazia `update_or_create`/`get_or_create` **um item por vez** — aceitável nos ~300 registros da amostra inicial (~2 idas ao banco por item), mas o export real de "com finalizadas" tem ~1700 ocorrências/~3400 ações/~9000 acompanhamentos — quase 28 mil idas ao banco. Reescrito pra lote: pré-carrega o que já existe (poucos `SELECT ... WHERE codigo IN (...)`) e aplica tudo de uma vez (`bulk_create`/`bulk_update` por model, `batch_size=500`) — e, mais importante pro caso real de reimportação periódica, **pula por completo quem não mudou nada** desde a última importação (reimportar o Sigsistem inteiro todo mês traz de volta o histórico completo, não só o que é novo). Resultado medido com os arquivos reais: primeira carga ~21s (a maioria virando "atualizada" pela primeira vez), reimportação do mesmo arquivo sem nenhuma mudança real caiu de ~60s pra **~4s** (a maior parte disso é upload do arquivo de 23MB + parsing, não mais banco).
Efeito colateral documentado (não é regressão, é a semântica correta): `resumo["ocorrencias_atualizadas"]`/`["acoes_atualizadas"]` agora contam só quem teve algum campo realmente alterado, não "já existia e foi vista de novo". Nenhuma migração, nenhuma mudança de contrato de API — só a implementação interna do upsert. Detalhe técnico em `portal_api/nao_conformidades/CLAUDE.md`, seção "Upsert".

View File

@ -46,7 +46,13 @@ A tratativa de fato (prorrogar/finalizar uma ação, decidir sobre uma análise)
### 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`): `update_or_create` de `NCOcorrencia`/`NCAcao` por código, `get_or_create` de `NCAcompanhamento` deduplicado por `hash_entrada` (sha256 de data+autor+texto — idempotente entre reimportações do mesmo arquivo), e é aqui que a checagem de reabertura roda (só quando o item já estava `tratado`). Roda síncrono dentro de `transaction.atomic()`, mesmo padrão de `ImportacaoPlanoSaudeViewSet.create()`/`IndicadorApuracaoViewSet.create()` — volume real processa em bem menos de 1s.
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`)
@ -101,5 +107,6 @@ Reaproveita `.status-pill` (definido em `perfis-acesso.css`) com modificadores n
## Riscos conhecidos
1. **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.
2. **`NCOcorrencia.codigo`/`NCAcao.codigo` como `PositiveIntegerField`**: assumido do comportamento do parser de referência, validado só contra a amostra de 236 ocorrências/290 ações. Se um export real trouxer código não-numérico, a linha é descartada silenciosamente (mesmo comportamento de "linha inválida" da skill original) — considerar `CharField` se isso acontecer.
3. **Upload síncrono**: aceitável no volume validado (processa em bem menos de 1s); se o Sigsistem exportar volumes muito maiores no futuro, reavaliar fila assíncrona (nenhum outro pacote do Portal usa hoje).
2. **`NCOcorrencia.codigo`/`NCAcao.codigo` como `PositiveIntegerField`**: 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) — considerar `CharField` se isso acontecer.
3. **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).
4. **`Data de Finalização`/`Dias para Finalização` seguem 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).

View File

@ -10,6 +10,7 @@ from __future__ import annotations
from datetime import date
STATUS_FINALIZADA = "finalizada"
STATUS_VENCIDA = "vencida"
STATUS_VENCE_7 = "vence_7_dias"
STATUS_VENCE_30 = "vence_30_dias"
@ -17,6 +18,7 @@ STATUS_NO_PRAZO = "no_prazo"
STATUS_SEM_VENCIMENTO = "sem_vencimento"
STATUS_PRAZO_LABELS = {
STATUS_FINALIZADA: "Finalizada",
STATUS_VENCIDA: "Vencida",
STATUS_VENCE_7: "Vence em ≤7 dias",
STATUS_VENCE_30: "Vence em ≤30 dias",
@ -47,7 +49,16 @@ def categoria_acao(tipo_acao: str) -> str:
return CATEGORIA_OUTROS
def status_prazo(venc: date | None, hoje: date) -> str:
def status_prazo(venc: date | None, hoje: date, finalizada: bool = False) -> str:
"""`finalizada=True` (ação com "Data de Finalização" preenchida no
Sigsistem) sempre vence qualquer outra classificação — uma ação já
concluída nunca é "vencida"/"vence em X dias", mesmo que o prazo dela já
tenha passado há muito tempo (comum em export com histórico: o
`vencimento_efetivo` congelado continua no passado depois de concluída).
Bug real corrigido: antes disso, reimportar um export com histórico de
ações já finalizadas fazia a maioria aparecer como "vencida"."""
if finalizada:
return STATUS_FINALIZADA
if venc is None:
return STATUS_SEM_VENCIMENTO
if venc < hoje:

View File

@ -2106,6 +2106,7 @@ class NCAcaoSerializer(serializers.ModelSerializer):
"situacao",
"fase",
"vencimento_efetivo",
"data_finalizacao",
"ultimo_acompanhamento_em",
"ultimo_acompanhamento_eh_prorrogacao",
"status_tratativa",
@ -2118,12 +2119,16 @@ class NCAcaoSerializer(serializers.ModelSerializer):
]
def get_status_prazo(self, obj: NCAcao) -> str:
return nc_classificacao.status_prazo(obj.vencimento_efetivo, timezone.localdate())
return nc_classificacao.status_prazo(
obj.vencimento_efetivo, timezone.localdate(), finalizada=bool(obj.data_finalizacao)
)
def get_status_prazo_label(self, obj: NCAcao) -> str:
return nc_classificacao.STATUS_PRAZO_LABELS[self.get_status_prazo(obj)]
def get_dias_para_vencer(self, obj: NCAcao) -> int | None:
if obj.data_finalizacao:
return None
return nc_classificacao.dias_para_vencer(obj.vencimento_efetivo, timezone.localdate())

View File

@ -2694,9 +2694,24 @@ def _snapshot_nc_acao_persistida(acao: NCAcao) -> dict[str, Any]:
def _aplica_upsert_nao_conformidades(resultado: Any, agora: Any) -> dict[str, Any]:
"""Upsert de NCOcorrencia/NCAcao/NCAcompanhamento a partir do resultado do
pipeline — e é aqui, não no pacote puro, que mora a decisão de reabrir um
item que já estava tratado (ver `nao_conformidades.diff`), porque só a
view sabe o que já está persistido."""
pipeline — em lote (`bulk_create`/`bulk_update`), não um `.save()` por
item. Uma exportação real do Sigsistem já chegou a ~1700 ocorrências/
~3400 ações/~9000 acompanhamentos — a versão anterior (um
`update_or_create`/`get_or_create` por item) fazia ~2 idas ao banco por
item, quase 28 mil no total, ~60s numa máquina local. Isso não é só
lento: em produção (gunicorn atrás de nginx, timeout padrão de worker de
30s) uma requisição de 60s é derrubada no meio, e o navegador só vê uma
resposta genérica de erro — sem nenhum problema real de dado por trás.
Aqui viram ~8 idas ao banco no total: pré-carrega o que já existe (2
querysets), aplica tudo em lote (`bulk_create`/`bulk_update` por model,
com `batch_size` pra não estourar o limite de parâmetros de uma única
query do Postgres num lote desse tamanho).
A decisão de reabertura (`nao_conformidades.diff`) continua rodando por
item em memória — é só comparação de dicts/hash, não é o gargalo; só a
gravação em si virou lote. É por isso que a view (não o pacote puro)
continua sendo dona desta função: ela precisa saber o que já está
persistido pra decidir "novo" vs. "atualizar" vs. "reabrir"."""
resumo: dict[str, Any] = {
"ocorrencias_novas": 0,
"ocorrencias_atualizadas": 0,
@ -2706,58 +2721,170 @@ def _aplica_upsert_nao_conformidades(resultado: Any, agora: Any) -> dict[str, An
"itens_reabertos": [],
}
# ---------- Ocorrências ----------
ocorrencias_existentes = {
oc.codigo: oc
for oc in NCOcorrencia.objects.filter(codigo__in=[o.codigo for o in resultado.ocorrencias])
}
ocorrencias_novas: list[NCOcorrencia] = []
ocorrencias_para_atualizar: list[NCOcorrencia] = []
mapa_ocorrencias: dict[int, NCOcorrencia] = {}
for ocorrencia_extraida in resultado.ocorrencias:
ocorrencia, criada = NCOcorrencia.objects.update_or_create(
codigo=ocorrencia_extraida.codigo,
defaults={**_campos_nc_ocorrencia(ocorrencia_extraida), "ultima_importacao_em": agora},
)
resumo["ocorrencias_novas" if criada else "ocorrencias_atualizadas"] += 1
campos = _campos_nc_ocorrencia(ocorrencia_extraida)
existente = ocorrencias_existentes.get(ocorrencia_extraida.codigo)
if existente is None:
nova = NCOcorrencia(codigo=ocorrencia_extraida.codigo, ultima_importacao_em=agora, **campos)
ocorrencias_novas.append(nova)
mapa_ocorrencias[ocorrencia_extraida.codigo] = nova
resumo["ocorrencias_novas"] += 1
continue
if not criada and ocorrencia.status_tratativa == NCOcorrencia.STATUS_TRATADO and ocorrencia.snapshot_tratativa:
motivo = nc_diff.decide_reabertura_ocorrencia(
ocorrencia.snapshot_tratativa, nc_diff.snapshot_ocorrencia(ocorrencia_extraida)
mapa_ocorrencias[ocorrencia_extraida.codigo] = existente
# Reimportações periódicas trazem de volta o histórico inteiro, não só
# o que mudou — pular quem não mudou nada evita reescrever milhares
# de linhas idênticas a cada importação (é o caso comum; só uma
# fração pequena do arquivo costuma ter novidade real).
mudou = any(getattr(existente, campo) != valor for campo, valor in campos.items())
motivo_reabertura = None
if existente.status_tratativa == NCOcorrencia.STATUS_TRATADO and existente.snapshot_tratativa:
motivo_reabertura = nc_diff.decide_reabertura_ocorrencia(
existente.snapshot_tratativa, nc_diff.snapshot_ocorrencia(ocorrencia_extraida)
)
if motivo:
ocorrencia.status_tratativa = NCOcorrencia.STATUS_PENDENTE
ocorrencia.reaberto_em = agora
ocorrencia.reaberto_motivo = motivo
ocorrencia.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{"tipo": "ocorrencia", "codigo": ocorrencia.codigo, "motivo": motivo}
)
ocorrencia.save()
if not mudou and not motivo_reabertura:
continue
for campo, valor in campos.items():
setattr(existente, campo, valor)
existente.ultima_importacao_em = agora
if motivo_reabertura:
existente.status_tratativa = NCOcorrencia.STATUS_PENDENTE
existente.reaberto_em = agora
existente.reaberto_motivo = motivo_reabertura
existente.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{"tipo": "ocorrencia", "codigo": existente.codigo, "motivo": motivo_reabertura}
)
ocorrencias_para_atualizar.append(existente)
resumo["ocorrencias_atualizadas"] += 1
if ocorrencias_novas:
NCOcorrencia.objects.bulk_create(ocorrencias_novas, batch_size=500)
if ocorrencias_para_atualizar:
# Lista de campos derivada do próprio dict de `_campos_nc_ocorrencia`
# (nunca hardcoded solta) — impossível ficar dessincronizada se
# aquela função ganhar/perder um campo no futuro.
campos_bulk = list(_campos_nc_ocorrencia(resultado.ocorrencias[0]).keys()) + [
"ultima_importacao_em",
"status_tratativa",
"reaberto_em",
"reaberto_motivo",
"snapshot_tratativa",
]
NCOcorrencia.objects.bulk_update(ocorrencias_para_atualizar, campos_bulk, batch_size=500)
# ---------- Ações ----------
acoes_existentes = {
(a.ocorrencia_id, a.codigo): a
for a in NCAcao.objects.filter(ocorrencia_id__in=[oc.id for oc in ocorrencias_existentes.values()])
}
acoes_novas: list[NCAcao] = []
acoes_para_atualizar: list[NCAcao] = []
mapa_acoes: dict[tuple[int, int], NCAcao] = {}
for ocorrencia_extraida in resultado.ocorrencias:
ocorrencia = mapa_ocorrencias[ocorrencia_extraida.codigo]
for acao_extraida in ocorrencia_extraida.acoes:
acao, acao_criada = NCAcao.objects.update_or_create(
ocorrencia=ocorrencia,
codigo=acao_extraida.codigo,
defaults={**_campos_nc_acao(acao_extraida), "ultima_importacao_em": agora},
)
resumo["acoes_novas" if acao_criada else "acoes_atualizadas"] += 1
campos = _campos_nc_acao(acao_extraida)
existente = acoes_existentes.get((ocorrencia.id, acao_extraida.codigo))
if existente is None:
nova = NCAcao(
ocorrencia=ocorrencia, codigo=acao_extraida.codigo, ultima_importacao_em=agora, **campos
)
acoes_novas.append(nova)
mapa_acoes[(ocorrencia_extraida.codigo, acao_extraida.codigo)] = nova
resumo["acoes_novas"] += 1
continue
mapa_acoes[(ocorrencia_extraida.codigo, acao_extraida.codigo)] = existente
mudou = any(getattr(existente, campo) != valor for campo, valor in campos.items())
motivo_reabertura = None
if existente.status_tratativa == NCAcao.STATUS_TRATADO and existente.snapshot_tratativa:
motivo_reabertura = nc_diff.decide_reabertura_acao(
existente.snapshot_tratativa, nc_diff.snapshot_acao(acao_extraida)
)
if not mudou and not motivo_reabertura:
continue
for campo, valor in campos.items():
setattr(existente, campo, valor)
existente.ultima_importacao_em = agora
if motivo_reabertura:
existente.status_tratativa = NCAcao.STATUS_PENDENTE
existente.reaberto_em = agora
existente.reaberto_motivo = motivo_reabertura
existente.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{
"tipo": "acao",
"codigo": f"{ocorrencia_extraida.codigo}/{acao_extraida.codigo}",
"motivo": motivo_reabertura,
}
)
acoes_para_atualizar.append(existente)
resumo["acoes_atualizadas"] += 1
if acoes_novas:
NCAcao.objects.bulk_create(acoes_novas, batch_size=500)
if acoes_para_atualizar:
acao_exemplo = next(o.acoes[0] for o in resultado.ocorrencias if o.acoes)
campos_bulk_acao = list(_campos_nc_acao(acao_exemplo).keys()) + [
"ultima_importacao_em",
"status_tratativa",
"reaberto_em",
"reaberto_motivo",
"snapshot_tratativa",
]
NCAcao.objects.bulk_update(acoes_para_atualizar, campos_bulk_acao, batch_size=500)
# ---------- Acompanhamentos (append-only, deduplicados por hash) ----------
# `bulk_create(... , ignore_conflicts=True)`: ids de ações novas nunca
# colidem com hashes já existentes (pk nova, nunca usada antes) — o
# `ignore_conflicts` é rede de segurança só pra duplicata dentro do
# próprio arquivo importado, não pro caso normal.
hashes_existentes = set(
NCAcompanhamento.objects.filter(acao_id__in=[a.id for a in acoes_existentes.values()]).values_list(
"acao_id", "hash_entrada"
)
)
acompanhamentos_novos: list[NCAcompanhamento] = []
hashes_no_lote: set[tuple[int, str]] = set()
for ocorrencia_extraida in resultado.ocorrencias:
for acao_extraida in ocorrencia_extraida.acoes:
acao = mapa_acoes[(ocorrencia_extraida.codigo, acao_extraida.codigo)]
for entrada in acao_extraida.acompanhamentos:
_, criado_acomp = NCAcompanhamento.objects.get_or_create(
acao=acao,
hash_entrada=_hash_acompanhamento(entrada),
defaults={"data": entrada.data, "autor": entrada.autor, "texto": entrada.texto,
"eh_prorrogacao": entrada.eh_prorrogacao},
)
if criado_acomp:
resumo["acompanhamentos_novos"] += 1
if not acao_criada and acao.status_tratativa == NCAcao.STATUS_TRATADO and acao.snapshot_tratativa:
motivo = nc_diff.decide_reabertura_acao(
acao.snapshot_tratativa, nc_diff.snapshot_acao(acao_extraida)
)
if motivo:
acao.status_tratativa = NCAcao.STATUS_PENDENTE
acao.reaberto_em = agora
acao.reaberto_motivo = motivo
acao.snapshot_tratativa = None
resumo["itens_reabertos"].append(
{"tipo": "acao", "codigo": f"{ocorrencia.codigo}/{acao.codigo}", "motivo": motivo}
hash_entrada = _hash_acompanhamento(entrada)
chave = (acao.id, hash_entrada)
if chave in hashes_existentes or chave in hashes_no_lote:
continue
hashes_no_lote.add(chave)
acompanhamentos_novos.append(
NCAcompanhamento(
acao=acao,
data=entrada.data,
autor=entrada.autor,
texto=entrada.texto,
eh_prorrogacao=entrada.eh_prorrogacao,
hash_entrada=hash_entrada,
)
acao.save()
)
if acompanhamentos_novos:
NCAcompanhamento.objects.bulk_create(acompanhamentos_novos, batch_size=500, ignore_conflicts=True)
resumo["acompanhamentos_novos"] = len(acompanhamentos_novos)
return resumo
@ -2866,7 +2993,18 @@ class NCOcorrenciaViewSet(viewsets.ModelViewSet):
def _filtra_status_prazo(queryset: QuerySet[NCAcao], bucket: str, hoje: date) -> QuerySet[NCAcao]:
"""Replica os mesmos cortes de `nao_conformidades.classificacao.status_prazo()`
como filtros de data sobre `vencimento_efetivo`, pra poder filtrar no banco
(o cálculo em si roda em Python, não dá pra usar direto num `.filter()`)."""
(o cálculo em si roda em Python, não dá pra usar direto num `.filter()`).
Todo bucket de prazo (vencida/vence em X dias/no prazo/sem vencimento)
exclui ações já finalizadas — bug real corrigido: um export com
histórico (`data_finalizacao` preenchida) tem `vencimento_efetivo` no
passado mesmo pra ação já concluída há muito tempo, o que fazia a
esmagadora maioria aparecer como "vencida" sem estar realmente em
aberto."""
if bucket == nc_classificacao.STATUS_FINALIZADA:
return queryset.filter(data_finalizacao__isnull=False)
queryset = queryset.filter(data_finalizacao__isnull=True)
if bucket == nc_classificacao.STATUS_VENCIDA:
return queryset.filter(vencimento_efetivo__lt=hoje)
if bucket == nc_classificacao.STATUS_VENCE_7:
@ -2979,8 +3117,8 @@ def nao_conformidades_dashboard_view(request: Request) -> Response:
ocorrencias_periodo = NCOcorrencia.objects.filter(data_emissao__gte=data_inicio, data_emissao__lte=data_fim)
status_counts = {chave: 0 for chave in nc_classificacao.STATUS_PRAZO_LABELS}
for venc in NCAcao.objects.values_list("vencimento_efetivo", flat=True):
status_counts[nc_classificacao.status_prazo(venc, hoje)] += 1
for venc, finalizada_em in NCAcao.objects.values_list("vencimento_efetivo", "data_finalizacao"):
status_counts[nc_classificacao.status_prazo(venc, hoje, finalizada=bool(finalizada_em))] += 1
ncs_sem_corretiva = 0
for ocorrencia in NCOcorrencia.objects.filter(tipo_ocorrencia__in=nc_classificacao.TIPOS_NC).prefetch_related(
@ -3040,10 +3178,15 @@ def nao_conformidades_dashboard_view(request: Request) -> Response:
return round(intervalo.total_seconds() / 86400, 1) if intervalo is not None else None
# Tempo de execução (abertura → finalização da ação) — só sobre ações com
# Data de Finalização preenchida (ver leiaute_ocorrencias.py). Quebrado por
# categoria_acao() em Python (Correção/Ação Corretiva/Outros variam demais
# entre si — de 1 a quase 800 dias na amostra real — pra uma média só fazer
# sentido sozinha).
# Data de Finalização preenchida (ver leiaute_ocorrencias.py). "Correção"/
# "Ação Corretiva" continuam agrupadas pela heurística de
# categoria_acao() (o texto real varia — "Correção", "Correção -
# Financeiro", "Correção - Desconto Indicadores" etc. — mas são a mesma
# categoria pra fins de NC/Reclamação). O resto ("Outros" pra
# categoria_acao()) é detalhado pelo texto real do "Tipo de Ação" em vez
# de virar um "Outros" só — pedido explícito do usuário, pra ver cada
# tipo separado (Oportunidade de Melhoria, Planejamento Estratégico
# etc.), já que esses não têm variação de texto na amostra real.
execucao_dias_por_tipo = NCAcao.objects.filter(
data_finalizacao__isnull=False, data_emissao__isnull=False
).annotate(
@ -3055,18 +3198,20 @@ def nao_conformidades_dashboard_view(request: Request) -> Response:
for tipo_acao, dias in execucao_dias_por_tipo:
valor = dias.total_seconds() / 86400
todos_dias.append(valor)
dias_por_categoria.setdefault(nc_classificacao.categoria_acao(tipo_acao), []).append(valor)
categoria = nc_classificacao.categoria_acao(tipo_acao)
chave = categoria if categoria != nc_classificacao.CATEGORIA_OUTROS else (tipo_acao or "Sem tipo informado")
dias_por_categoria.setdefault(chave, []).append(valor)
ordem_categorias = [
nc_classificacao.CATEGORIA_CORRECAO,
nc_classificacao.CATEGORIA_CORRETIVA,
nc_classificacao.CATEGORIA_OUTROS,
]
execucao_por_categoria = []
for categoria in ordem_categorias:
valores = dias_por_categoria.get(categoria)
if not valores:
continue
for categoria in (nc_classificacao.CATEGORIA_CORRECAO, nc_classificacao.CATEGORIA_CORRETIVA):
valores = dias_por_categoria.pop(categoria, None)
if valores:
execucao_por_categoria.append(
{"categoria": categoria, "media_dias": round(sum(valores) / len(valores), 1), "n": len(valores)}
)
# O restante (tipos reais fora de Correção/Ação Corretiva), do mais pro
# menos frequente.
for categoria, valores in sorted(dias_por_categoria.items(), key=lambda item: -len(item[1])):
execucao_por_categoria.append(
{"categoria": categoria, "media_dias": round(sum(valores) / len(valores), 1), "n": len(valores)}
)

File diff suppressed because it is too large Load Diff

View File

@ -212,6 +212,13 @@
margin: 0 0 var(--space-2);
}
.ncf-execucao-categoria__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
gap: var(--space-4);
align-items: start;
}
.ncf-dash-legend {
display: flex;
align-items: center;

View File

@ -34,6 +34,7 @@ function pidNcfAtualizarContador(el, quantidade) {
}
const PID_NCF_STATUS_PRAZO_CLASSES = {
finalizada: "status-pill status-pill--ok",
vencida: "status-pill status-pill--danger",
vence_7_dias: "status-pill status-pill--warning",
vence_30_dias: "status-pill status-pill--neutral",
@ -451,8 +452,12 @@ document.addEventListener("DOMContentLoaded", async () => {
// ---------------- Painel de detalhe de uma ação (lazy) — histórico ----------------
async function montarPainelAcao(painel, item) {
const idAcomp = `ncf-acomp-${item.id}`;
const finalizadaHtml = item.data_finalizacao
? `<p class="ncf-detail-panel__texto"><strong>Finalizada em:</strong> ${pidNcfFormatDate(item.data_finalizacao)}</p>`
: "";
painel.innerHTML = `
<p class="ncf-detail-panel__texto"><strong>Ação:</strong> ${pidNcfEscapeHtml(item.acao_texto) || "—"}</p>
${finalizadaHtml}
<h4>Histórico de acompanhamentos</h4>
<div id="${idAcomp}"><p class="ncf-detail-panel__loading">Carregando...</p></div>
`;
@ -594,20 +599,34 @@ document.addEventListener("DOMContentLoaded", async () => {
async function carregarDashboard() {
const dados = await pidApiRequest(`/nao-conformidades/dashboard/?meses=${encodeURIComponent(periodoAtual)}`);
const totalAcoes = Object.values(dados.acoes_abertas_por_status || {}).reduce((a, b) => a + b, 0);
// "Ações abertas" exclui quem já foi finalizado no Sigsistem — uma ação
// concluída há anos não é mais um item de trabalho em aberto, mesmo que
// o prazo dela (congelado) já tenha passado há muito tempo.
const statusPorPrazo = dados.acoes_abertas_por_status || {};
const totalFinalizadas = statusPorPrazo.finalizada || 0;
const totalAcoes = Object.entries(statusPorPrazo).reduce(
(soma, [chave, valor]) => (chave === "finalizada" ? soma : soma + valor),
0
);
const cartoes = [
{ rotulo: "Ocorrências abertas", valor: dados.ocorrencias_abertas, tom: "" },
{ rotulo: "Ações abertas", valor: totalAcoes, tom: "" },
{
rotulo: "Ações finalizadas",
valor: totalFinalizadas,
tom: "",
acao: () => irParaAcoesComPrazo("finalizada"),
},
{
rotulo: "Ações vencidas",
valor: dados.acoes_abertas_por_status?.vencida || 0,
valor: statusPorPrazo.vencida || 0,
tom: "ncf-card--danger",
acao: () => irParaAcoesComPrazo("vencida"),
},
{
rotulo: "Vencem em ≤7 dias",
valor: dados.acoes_abertas_por_status?.vence_7_dias || 0,
valor: statusPorPrazo.vence_7_dias || 0,
tom: "ncf-card--warning",
acao: () => irParaAcoesComPrazo("vence_7_dias"),
},
@ -676,19 +695,23 @@ document.addEventListener("DOMContentLoaded", async () => {
if (!execucaoPorCategoria.length) {
dashExecucaoCategoria.innerHTML = "";
} else {
const linhas = execucaoPorCategoria
.map(
(item) =>
`<tr><td>${pidNcfEscapeHtml(item.categoria)}</td><td>${pidNcfFormatDias(item.media_dias)} dias</td><td>${item.n}</td></tr>`
)
.join("");
const linhaHtml = (item) =>
`<tr><td>${pidNcfEscapeHtml(item.categoria)}</td><td>${pidNcfFormatDias(item.media_dias)} dias</td><td>${item.n}</td></tr>`;
// Duas colunas lado a lado em vez de uma lista comprida — metade das
// categorias em cada uma (pedido do usuário: "4 de cada lado").
const metade = Math.ceil(execucaoPorCategoria.length / 2);
const colunas = [execucaoPorCategoria.slice(0, metade), execucaoPorCategoria.slice(metade)];
const tabelaHtml = (itens) =>
itens.length
? `<div class="pa-table-wrap"><table class="pa-table">
<thead><tr><th>Categoria</th><th>Média</th><th>Qtd.</th></tr></thead>
<tbody>${itens.map(linhaHtml).join("")}</tbody>
</table></div>`
: "";
dashExecucaoCategoria.innerHTML = `
<h4 class="ncf-execucao-categoria__title">Execução por categoria de ação</h4>
<div class="pa-table-wrap">
<table class="pa-table">
<thead><tr><th>Categoria</th><th>Média</th><th>Qtd.</th></tr></thead>
<tbody>${linhas}</tbody>
</table>
<div class="ncf-execucao-categoria__grid">
${colunas.map(tabelaHtml).join("")}
</div>
`;
}

View File

@ -455,6 +455,7 @@
<button type="button" class="ncf-chip" data-ncf-prazo="vence_30_dias">Vence em 30 dias</button>
<button type="button" class="ncf-chip" data-ncf-prazo="no_prazo">No prazo</button>
<button type="button" class="ncf-chip" data-ncf-prazo="sem_vencimento">Sem vencimento</button>
<button type="button" class="ncf-chip" data-ncf-prazo="finalizada">Finalizadas</button>
</div>
</div>
<div class="ncf-filtro-grupo">