Commit inicial: app de exportação da SD + deploy Docker/Coolify
Aplicação Flask (enviar SD.md -> corrigir pendências -> exportar JSON), scripts de contrato (scripts/) e infraestrutura de deploy (Dockerfile, docker-compose.yml) para VPS via Coolify. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,53 @@
|
||||
"""Caminhos canônicos do repositório — o único lugar que sabe onde a árvore está.
|
||||
|
||||
Mudança de estrutura é mudança aqui, e em mais lugar nenhum. A lição veio da
|
||||
reorganização V0: eram 103 referências de caminho espalhadas por scripts,
|
||||
Makefile e harness, e mover uma pasta quebrava tudo entre a mudança e a
|
||||
reescrita.
|
||||
"""
|
||||
from pathlib import Path
|
||||
|
||||
RAIZ = Path(__file__).resolve().parent.parent
|
||||
CLIENTE = RAIZ / "clientes" / "ses-mg"
|
||||
CONTRATO = CLIENTE / "contrato"
|
||||
GOVERNANCA = CLIENTE / "governanca"
|
||||
PROJETOS = CLIENTE / "projetos"
|
||||
ENTRADA = CLIENTE / "_entrada"
|
||||
RELATORIOS = CLIENTE / "relatorios"
|
||||
COMUNICACOES = CLIENTE / "comunicacoes"
|
||||
METODOS = RAIZ / "metodos"
|
||||
|
||||
# _derivados/ é resultado, nunca fonte: gitignored, regenerável por make derivar.
|
||||
DERIVADOS = RAIZ / "_derivados"
|
||||
|
||||
ITENS = CONTRATO / "itens.yaml"
|
||||
|
||||
# Visões GERADAS a partir das SDs (inversão da V0, 13/08/2026). Quem lê
|
||||
# portfólio, carteira ou catálogo lê o resultado de `make derivar` — os
|
||||
# mantidos à mão estão congelados em governanca/levantamentos/.
|
||||
PORTFOLIO = DERIVADOS / "portfolio.yaml"
|
||||
CARTEIRA = DERIVADOS / "carteira.yaml"
|
||||
CATALOGO = DERIVADOS / "catalogo.md"
|
||||
|
||||
NUMERACAO = GOVERNANCA / "numeracao.yaml"
|
||||
TRANSVERSAL = GOVERNANCA / "transversal.yaml"
|
||||
TEMPLATE_SD = PROJETOS / "_template" / "SD.md"
|
||||
|
||||
# Modelo Word do material de leitura prévia (`make leitura-previa`): layout da
|
||||
# nota técnica com logo no cabeçalho. Vive em metodos/modelos/ — cópia neutra;
|
||||
# a original é evidência da P5·SD24 e evidência não é dependência de script.
|
||||
MODELO_NOTA_TECNICA = METODOS / "modelos" / "nota-tecnica.docx"
|
||||
|
||||
# Projeção financeira: gerada em _derivados/, congelada datada em comunicacoes/.
|
||||
PROJECAO_HTML = DERIVADOS / "projecao-executiva.html"
|
||||
APRESENTACOES = COMUNICACOES / "apresentacoes"
|
||||
|
||||
|
||||
def sds_reais():
|
||||
"""Todos os SD.md sob projetos/*/sds/.
|
||||
|
||||
O template vive em projetos/_template/, fora de qualquer */sds/* — é o que
|
||||
o mantém fora desta varredura por construção, em todos os consumidores de
|
||||
uma vez. Era ele contaminando a varredura de faturamento na árvore antiga.
|
||||
"""
|
||||
return sorted(PROJETOS.glob("*/sds/*/SD.md"))
|
||||
@@ -0,0 +1,711 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
exporta_sd.py — gera o JSON de carga do sistema de gestão a partir do SD.md.
|
||||
|
||||
O SD.md é a fonte. Este script deriva tudo o que é derivável e monta o payload
|
||||
que `npm run import:sd` consome — o contrato está em CONTRATO-JSON-V2.md, e
|
||||
este arquivo é a implementação dele na origem.
|
||||
|
||||
A regra que governa tudo aqui: NUNCA sai JSON com pendência aberta. Um arquivo
|
||||
que o importador vai rejeitar não é uma exportação, é um problema adiado para
|
||||
onde não há contexto para corrigi-lo. Então montar() ou devolve o payload
|
||||
inteiro, ou levanta ExportacaoReprovada com a lista de pendências — cada uma
|
||||
apontando o campo e o entregável, para que a tela abra o formulário só do que
|
||||
falta e a CLI diga exatamente o que mudar no .md.
|
||||
|
||||
O que é DERIVADO aqui, nunca lido do arquivo:
|
||||
· semanas — max(1, ceil(dias/7)) sobre inicio/fim. As DATAS mandam: o
|
||||
`semanas` do .md é conferido contra isto e reprova se divergir.
|
||||
· status — do `estado` da SD, nos rótulos exatos da §6 do contrato
|
||||
· código, alocações (matriz de perfis de itens.yaml), prazo, titulo_literal
|
||||
|
||||
O que é derivado PELO IMPORTADOR, e por isso sai `null` daqui (decisão da
|
||||
gestão em 2026-09-03, aceita pelo time do sistema — ver
|
||||
PROPOSTA-CONTRATO-V2-derivacao.md): `ust`, `valor_unitario_ust` e
|
||||
`valor_entregavel`. Time-box por item × tipo e tarifa vigente são tabelas do
|
||||
sistema de gestão; copiá-las aqui era manter um retrato que envelhecia a cada
|
||||
reajuste. As chaves continuam no JSON, na mesma posição — só o valor é `null`.
|
||||
`horas_semanais` e `memoria_calculo`, que só existiam para explicar a conta,
|
||||
saem `null` pelo mesmo motivo.
|
||||
|
||||
O que NÃO é derivado, por decisão da gestão (2026-09-03): `ordem_servico.linha`.
|
||||
OS e linha são cadastro do sistema de gestão e mudam durante o ano; um de-para
|
||||
local envelheceria em silêncio. Quem emite a SD declara `linha_os` no
|
||||
entregável, copiado da tela da OS; aqui só o formato `{OS}-L{n}` é conferido,
|
||||
e ausente é pendência. Existência, item e status quem confere é o banco, na
|
||||
carga — que recusa nomeando o que falta.
|
||||
|
||||
O que este script NÃO faz, por decisão da gestão (2026-09-03): não confere os
|
||||
tetos de texto da §2/§3/§4 (nome ≤ 160, objetivo ≤ 4000 etc.) — o importador
|
||||
rejeita e informa. Campos extras à estrutura do contrato (codigo, memoria_calculo,
|
||||
_servico, totais...) continuam saindo: o importador os ignora, e a ESTRUTURA do
|
||||
JSON não muda — só os valores.
|
||||
|
||||
Cuidado: `totais.prazo_calendario_semanas` é duração de CALENDÁRIO da SD, decimal
|
||||
de propósito, e NÃO usa a regra de semanas do entregável. São grandezas
|
||||
diferentes — ver o comentário no cálculo do prazo antes de tentar unificar.
|
||||
|
||||
Uso:
|
||||
python3 scripts/exporta_sd.py sds/P2-SD8-mvp-vacina-em-dia/SD.md
|
||||
python3 scripts/exporta_sd.py sds/*/SD.md --dir _derivados/export
|
||||
|
||||
Contrato de saída (para quem chama de make/CI): cada SD é exportada de forma
|
||||
independente — uma SD com pendência não impede as outras. Código 0 = todas
|
||||
saíram; 1 = alguma ficou pendente, as demais saíram normalmente, e as que
|
||||
faltaram vão nomeadas em stderr com as pendências. Nunca trate 1 como "nada
|
||||
foi gerado".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import glob
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from regras_sd import (
|
||||
Canonico,
|
||||
ESTADO_SD_PARA_ENTREGAVEL,
|
||||
ESTADO_SD_PARA_STATUS_SD,
|
||||
ESTADOS_SD,
|
||||
JANELA_MAXIMA_DIAS,
|
||||
PERFIS_DO_CADASTRO,
|
||||
TIPOS_IMPORTAVEIS,
|
||||
TIPOS_QUE_PARAM_A_CARGA,
|
||||
carregar_canonico,
|
||||
ITENS_FORA_DA_CARGA,
|
||||
checar_dependencias,
|
||||
como_data,
|
||||
como_iso,
|
||||
conferir_formato_linha_os,
|
||||
enquadramento_tr,
|
||||
janela_dias,
|
||||
ler_sd_arquivo,
|
||||
perfil_do_cadastro,
|
||||
secao,
|
||||
semanas_por_datas,
|
||||
)
|
||||
|
||||
ABREV_TIPO = {"Descoberta": "D", "Design": "DE", "Arquitetura": "A", "Construção": "C"}
|
||||
|
||||
# "P2·SD8" — projeto, separador canônico, número global da SD.
|
||||
_SD_ID = re.compile(r"^\s*(?P<proj>[^·\s]+)\s*·\s*SD(?P<num>\d+)\s*$")
|
||||
_VERSAO = re.compile(r"^V\d+$")
|
||||
# Convenção do repositório para "não sei": o _template/SD.md proíbe datas como
|
||||
# "não formalizada" pelo mesmo motivo. Um PO assim não existe no cadastro.
|
||||
_PENDENTE = re.compile(r"^\s*pendente\b", re.I)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pendências
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass
|
||||
class Pendencia:
|
||||
"""O que impede o JSON de sair, apontando onde se corrige.
|
||||
|
||||
`escopo` diz de onde o campo vem: "sd" (frontmatter, nível da SD),
|
||||
"entregavel" (frontmatter, `entregaveis[indice]`) ou "corpo" (a prosa —
|
||||
hoje só o objetivo, Seção 1). `campo` é a CHAVE DO SD.md, não a do JSON:
|
||||
é no .md que a pessoa corrige, e é ele que o formulário edita.
|
||||
|
||||
`indice` é a posição na lista, e não o `n`: `n` pode faltar ou repetir —
|
||||
e as duas coisas são pendências que precisam apontar para algum lugar.
|
||||
`opcoes` fecha a lista quando ela é fechada (estado, tipo, item, linha);
|
||||
`derivado` é o valor que a regra calculou, para a tela sugerir.
|
||||
"""
|
||||
escopo: str
|
||||
campo: str
|
||||
mensagem: str
|
||||
indice: int | None = None
|
||||
n: object = None
|
||||
opcoes: list | None = None
|
||||
derivado: object = None
|
||||
|
||||
@property
|
||||
def chave(self) -> str:
|
||||
"""Nome do campo no formulário — uma pendência, um controle."""
|
||||
if self.escopo == "entregavel":
|
||||
return f"e{self.indice}__{self.campo}"
|
||||
return f"{self.escopo}__{self.campo}"
|
||||
|
||||
@property
|
||||
def onde(self) -> str:
|
||||
if self.escopo == "entregavel":
|
||||
rot = f"entregável {self.n}" if self.n not in (None, "") else f"entregável #{self.indice + 1}"
|
||||
return f"{rot} · {self.campo}"
|
||||
if self.escopo == "corpo":
|
||||
return f"Seção do corpo · {self.campo}"
|
||||
return f"SD · {self.campo}"
|
||||
|
||||
def __str__(self) -> str:
|
||||
return f"{self.onde}: {self.mensagem}"
|
||||
|
||||
|
||||
class ExportacaoReprovada(ValueError):
|
||||
"""montar() não produziu JSON. `pendencias` diz por quê, campo a campo."""
|
||||
|
||||
def __init__(self, pendencias: list[Pendencia]):
|
||||
self.pendencias = list(pendencias)
|
||||
super().__init__("\n".join(f" · {p}" for p in self.pendencias))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Peças reaproveitadas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def alocacoes_do_tipo(canon: dict, tipo: str) -> list[dict]:
|
||||
"""Matriz de perfis do TR 4.1.1.7.1 aplicada ao tipo do entregável.
|
||||
|
||||
`null` na matriz significa que o perfil NÃO CONSTA da tabela daquela sprint
|
||||
no TR — diferente de constar com 0%. Perfil ausente não entra na lista.
|
||||
O nome sai como o CADASTRO do cliente o conhece (regras_sd.PERFIL_ALIASES),
|
||||
e não como o itens.yaml o transcreve do TR.
|
||||
"""
|
||||
matriz = ((canon.get("composicao_perfis") or {}).get("matriz")) or {}
|
||||
saida = []
|
||||
for perfil, pcts in matriz.items():
|
||||
pct = pcts.get(tipo)
|
||||
if pct is None or pct == 0:
|
||||
continue
|
||||
saida.append({
|
||||
"perfil": perfil_do_cadastro(perfil),
|
||||
"quantidade": pcts.get("quantidade", 1),
|
||||
"percentual_alocacao": pct,
|
||||
})
|
||||
return saida
|
||||
|
||||
|
||||
def _json_default(o):
|
||||
"""Último recurso do json.dumps para o que o YAML devolve e o JSON não conhece.
|
||||
|
||||
Os campos DERIVADOS passam por como_iso(). Os blocos que o exportador copia
|
||||
inteiros do frontmatter — aceites_documento, os_mae — não passam por nada,
|
||||
e uma data crua ali chegava ao json.dumps e estourava
|
||||
`TypeError: Object of type date is not JSON serializable`. Seis das doze SDs
|
||||
redigidas não geravam JSON nenhum por isso.
|
||||
|
||||
Fica no ponto de saída, não no campo: qualquer data nova num bloco copiado
|
||||
passa a funcionar sem mexer no exportador. E SÓ data — um `default=str`
|
||||
genérico viraria texto silencioso qualquer objeto que não devia estar no
|
||||
payload, exatamente a divergência silenciosa que este script existe para
|
||||
impedir.
|
||||
|
||||
Usa str() e não .isoformat() de propósito: para date os dois são idênticos,
|
||||
e str() reproduz byte a byte o contorno já validado contra o script oficial.
|
||||
"""
|
||||
if isinstance(o, date): # datetime é subclasse de date; ambos caem aqui
|
||||
return str(o)
|
||||
raise TypeError(
|
||||
f"{type(o).__name__} não é serializável em JSON (valor: {o!r}). "
|
||||
"Se for campo derivado, passe por como_iso(); se for bloco copiado do "
|
||||
"frontmatter, o tipo não deveria estar lá.")
|
||||
|
||||
|
||||
def serializar(payload: dict) -> str:
|
||||
"""Texto exato do arquivo .json. Ponto único, para que a CLI e a aplicação
|
||||
web produzam os MESMOS BYTES — se divergirem, deixaram de ser a mesma régua.
|
||||
"""
|
||||
return json.dumps(payload, ensure_ascii=False, indent=2, default=_json_default) + "\n"
|
||||
|
||||
|
||||
def bloqueador_como_texto(b) -> str:
|
||||
"""Achata um bloqueador do frontmatter para a string que o payload carrega.
|
||||
|
||||
O sistema de destino recebe `_governanca.bloqueadores` como lista de TEXTOS.
|
||||
No SD.md a forma estruturada (o_que/dono/aberto_em/status/destrava/nota)
|
||||
continua sendo a preferida, e é o exportador que resolve a diferença.
|
||||
|
||||
A régua: `o_que` INTEIRO + " — " + `dono` truncado no primeiro " — ".
|
||||
· o_que nunca é truncado: "Gate 3 — perímetro LGPD..." tem o conteúdo
|
||||
todo depois do travessão — cortar ali viraria "Gate 3", que não diz nada.
|
||||
· dono é truncado porque o sufixo dele é anotação de trabalho, rastreável
|
||||
no SD.md; no payload interessa QUEM destrava.
|
||||
Bloqueador `resolvido` também é exportado: filtrar seria decisão de negócio
|
||||
que o exportador não toma sozinho.
|
||||
"""
|
||||
if not isinstance(b, dict):
|
||||
return " ".join(str(b).split())
|
||||
o_que = " ".join(str(b.get("o_que") or "").split())
|
||||
dono = " ".join(str(b.get("dono") or "").split())
|
||||
dono = dono.split(" — ", 1)[0].strip()
|
||||
return f"{o_que} — {dono}" if dono else o_que
|
||||
|
||||
|
||||
def _texto(v) -> str:
|
||||
return " ".join(str(v).split()) if v is not None else ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Montagem
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def analisar(sd: dict, corpo: str, canon: Canonico) -> tuple[dict, list[Pendencia]]:
|
||||
"""(prévia, pendências) numa passada só — para a tela.
|
||||
|
||||
A prévia é o payload como ele ESTÁ, com os buracos que as pendências
|
||||
apontam (linha None, ust None, po None). Serve para mostrar o que a
|
||||
exportação vai conter; NUNCA para serializar — quem exporta chama montar().
|
||||
"""
|
||||
pend: list[Pendencia] = []
|
||||
payload = _montar(sd, corpo, canon, pend)
|
||||
return payload, pend
|
||||
|
||||
|
||||
def conferir(sd: dict, corpo: str, canon: Canonico) -> list[Pendencia]:
|
||||
"""Só as pendências, sem levantar."""
|
||||
return analisar(sd, corpo, canon)[1]
|
||||
|
||||
|
||||
def montar(sd: dict, corpo: str, canon: Canonico) -> dict:
|
||||
"""O payload completo — ou ExportacaoReprovada com as pendências.
|
||||
|
||||
Uma passada só: a mesma função que monta é a que confere, e por isso não
|
||||
existe caminho em que o JSON sai com um campo que a conferência reprovaria.
|
||||
"""
|
||||
pend: list[Pendencia] = []
|
||||
payload = _montar(sd, corpo, canon, pend)
|
||||
if pend:
|
||||
raise ExportacaoReprovada(pend)
|
||||
return payload
|
||||
|
||||
|
||||
def _montar(sd: dict, corpo: str, canon: Canonico, pend: list[Pendencia]) -> dict:
|
||||
itens_canon: dict = canon.itens
|
||||
itens_validos = sorted(itens_canon.get("itens") or {})
|
||||
itens_importaveis = [i for i in itens_validos if i not in ITENS_FORA_DA_CARGA]
|
||||
|
||||
def falta_sd(campo, msg, **kw):
|
||||
pend.append(Pendencia("sd", campo, msg, **kw))
|
||||
|
||||
# -- identificação ------------------------------------------------------
|
||||
sd_id = _texto(sd.get("sd"))
|
||||
m = _SD_ID.match(sd_id)
|
||||
if m:
|
||||
proj, num = m.group("proj"), m.group("num")
|
||||
else:
|
||||
proj, num = "", ""
|
||||
falta_sd("sd", f"identificador {sd_id!r} fora da notação `P{{n}}·SD{{n}}` (ex.: P2·SD8) — "
|
||||
"dele saem sd.numero_sequencial e sd.projeto.codigo")
|
||||
|
||||
titulo = _texto(sd.get("titulo"))
|
||||
if not titulo:
|
||||
falta_sd("titulo", "a SD precisa de título (sd.nome é obrigatório na carga)")
|
||||
|
||||
po = _texto(sd.get("po_responsavel"))
|
||||
if not po:
|
||||
falta_sd("po_responsavel", "PO responsável em branco — a carga exige um nome que exista "
|
||||
"no cadastro de POs do sistema")
|
||||
elif _PENDENTE.match(po):
|
||||
falta_sd("po_responsavel", f"PO responsável {po!r} é a convenção do repositório para "
|
||||
"\"não sei\", não um nome do cadastro de POs")
|
||||
|
||||
versao = _texto(sd.get("versao"))
|
||||
if not _VERSAO.match(versao):
|
||||
falta_sd("versao", f"versão {versao!r} fora do formato `V{{n}}` (ex.: V1) — "
|
||||
"_governanca.versao_documento é obrigatório")
|
||||
|
||||
estado = sd.get("estado")
|
||||
if estado not in ESTADO_SD_PARA_STATUS_SD:
|
||||
falta_sd("estado", f"estado {estado!r} desconhecido — não dá para derivar sd.status nem o "
|
||||
"status dos entregáveis", opcoes=list(ESTADOS_SD))
|
||||
status_ent = ESTADO_SD_PARA_ENTREGAVEL.get(estado)
|
||||
status_sd = ESTADO_SD_PARA_STATUS_SD.get(estado)
|
||||
|
||||
data_abertura = None
|
||||
if sd.get("data_abertura") not in (None, ""):
|
||||
try:
|
||||
data_abertura = como_iso(como_data(sd.get("data_abertura")))
|
||||
except ValueError:
|
||||
falta_sd("data_abertura", f"data de abertura {sd.get('data_abertura')!r} ilegível — "
|
||||
"formato AAAA-MM-DD")
|
||||
|
||||
# -- entregáveis ----------------------------------------------------------
|
||||
lista = sd.get("entregaveis")
|
||||
if not isinstance(lista, list) or not lista:
|
||||
falta_sd("entregaveis", "a SD não tem entregáveis — a carga exige pelo menos um. "
|
||||
"Corrija no SD.md e envie de novo.")
|
||||
lista = []
|
||||
|
||||
entregaveis, seq = [], {}
|
||||
itens_da_sd: set = set()
|
||||
datas_ini, datas_fim = [], []
|
||||
ns_vistos: dict = {}
|
||||
|
||||
for i, e in enumerate(lista):
|
||||
n = e.get("n") if isinstance(e, dict) else None
|
||||
|
||||
def falta(campo, msg, **kw):
|
||||
pend.append(Pendencia("entregavel", campo, msg, indice=i, n=n, **kw))
|
||||
|
||||
if not isinstance(e, dict):
|
||||
falta("n", f"entrada #{i + 1} de `entregaveis` não é um mapa — é {type(e).__name__}. "
|
||||
"Corrija no SD.md e envie de novo.")
|
||||
continue
|
||||
|
||||
if not isinstance(n, int) or isinstance(n, bool) or n <= 0:
|
||||
falta("n", f"`n` {n!r} precisa ser inteiro maior que zero")
|
||||
elif n in ns_vistos:
|
||||
falta("n", f"`n` {n} repetido — já é o entregável #{ns_vistos[n] + 1}; "
|
||||
"`n` é único no arquivo")
|
||||
else:
|
||||
ns_vistos[n] = i
|
||||
|
||||
nome = _texto(e.get("nome"))
|
||||
if not nome:
|
||||
falta("nome", "entregável sem nome (titulo é obrigatório na carga)")
|
||||
|
||||
tipo, item = e.get("tipo"), e.get("item")
|
||||
tipo_ok = tipo in TIPOS_IMPORTAVEIS
|
||||
if not tipo_ok:
|
||||
if tipo in TIPOS_QUE_PARAM_A_CARGA:
|
||||
falta("tipo", f"tipo {tipo!r} não entra por esta carga (Regra 11) — entregável "
|
||||
"desse tipo é cadastrado pela tela do sistema",
|
||||
opcoes=list(TIPOS_IMPORTAVEIS))
|
||||
else:
|
||||
falta("tipo", f"tipo {tipo!r} inválido", opcoes=list(TIPOS_IMPORTAVEIS))
|
||||
|
||||
item_ok = item in itens_importaveis
|
||||
if not item_ok:
|
||||
if item in itens_validos:
|
||||
falta("item", f"item {item} não gera UST (licença) e não entra por esta carga",
|
||||
opcoes=itens_importaveis)
|
||||
else:
|
||||
falta("item", f"item {item!r} não existe no contrato canônico",
|
||||
opcoes=itens_importaveis)
|
||||
|
||||
# -- datas: as que mandam ------------------------------------------------
|
||||
a = b = None
|
||||
for campo in ("inicio", "fim"):
|
||||
bruto = e.get(campo)
|
||||
if bruto in (None, ""):
|
||||
falta(campo, f"`{campo}` ausente — data_inicio e data_prevista_termino são "
|
||||
"obrigatórias, e delas saem semanas, UST e valor")
|
||||
continue
|
||||
try:
|
||||
d = como_data(bruto)
|
||||
except ValueError:
|
||||
falta(campo, f"`{campo}` {bruto!r} ilegível — formato AAAA-MM-DD, dia real")
|
||||
continue
|
||||
if campo == "inicio":
|
||||
a = d
|
||||
else:
|
||||
b = d
|
||||
|
||||
semanas = None
|
||||
if a and b:
|
||||
dias = janela_dias(a, b)
|
||||
if dias < 0:
|
||||
falta("fim", f"fim {b} anterior ao início {a}")
|
||||
elif dias > JANELA_MAXIMA_DIAS:
|
||||
falta("fim", f"janela de {dias} dias ({a} → {b}) passa do teto de "
|
||||
f"{JANELA_MAXIMA_DIAS} dias da carga — quebre em mais de um "
|
||||
"entregável no SD.md, ou encurte a janela")
|
||||
else:
|
||||
semanas = semanas_por_datas(a, b)
|
||||
declarado = e.get("semanas")
|
||||
if declarado not in (None, "") and declarado != semanas:
|
||||
falta("semanas", f"{dias} dias ({a} → {b}) dão {semanas} semana(s) pela regra "
|
||||
f"da carga, mas o SD declara {declarado} — UST e valor não "
|
||||
"fechariam na importação. Ajuste as datas ou as semanas.",
|
||||
derivado=semanas)
|
||||
|
||||
# UST não se declara: o sistema a deriva de datas, item e tipo na carga.
|
||||
# Valor digitado aqui só pode divergir da conta, sem ninguém notar.
|
||||
if e.get("ust") not in (None, ""):
|
||||
falta("ust", f"o SD declara {e.get('ust')} UST, mas UST é derivada pelo sistema "
|
||||
"na carga (time-box × semanas das datas). Apague a declaração.")
|
||||
|
||||
# -- linha de OS -----------------------------------------------------------
|
||||
# Declarada no SD.md por quem emite a SD, e só o formato é conferido
|
||||
# aqui. OS e linha são cadastro do sistema de gestão e mudam durante o
|
||||
# ano; quem sabe se existem é o banco, na carga. Não há de-para local.
|
||||
linha = None
|
||||
queixa = conferir_formato_linha_os(e.get("linha_os"))
|
||||
if queixa:
|
||||
falta("linha_os", queixa)
|
||||
else:
|
||||
linha = str(e.get("linha_os")).strip()
|
||||
|
||||
# -- listas filhas -----------------------------------------------------------
|
||||
for campo in ("atividades", "criterios_aceite"):
|
||||
for k, x in enumerate(e.get(campo) or [], 1):
|
||||
if not _texto(x):
|
||||
falta(campo, f"item {k} de `{campo}` está vazio — descricao é obrigatória")
|
||||
|
||||
artefatos = []
|
||||
nomes_vistos = set()
|
||||
for k, art in enumerate(e.get("artefatos") or [], 1):
|
||||
if not isinstance(art, dict):
|
||||
art = {"nome": art}
|
||||
nome_art = _texto(art.get("nome"))
|
||||
if not nome_art:
|
||||
falta("artefatos", f"artefato {k} sem nome — `documentacao[].nome` é obrigatório")
|
||||
continue
|
||||
if nome_art in nomes_vistos:
|
||||
falta("artefatos", f"artefato {nome_art!r} repetido — o nome é a chave que a "
|
||||
"reimportação usa para reconhecer documento já entregue")
|
||||
nomes_vistos.add(nome_art)
|
||||
data_art = None
|
||||
if art.get("data") not in (None, ""):
|
||||
try:
|
||||
data_art = como_iso(como_data(art.get("data")))
|
||||
except ValueError:
|
||||
falta("artefatos", f"artefato {nome_art!r}: data {art.get('data')!r} ilegível")
|
||||
artefatos.append({"nome": nome_art, "data": data_art, "status": "PREVISTO"})
|
||||
|
||||
alocacoes = None
|
||||
if e.get("alocacoes_desvio"):
|
||||
alocacoes = []
|
||||
for k, al in enumerate(e["alocacoes_desvio"], 1):
|
||||
if not isinstance(al, dict):
|
||||
falta("alocacoes_desvio", f"alocação {k} não é um mapa perfil/quantidade/percentual")
|
||||
continue
|
||||
perfil = perfil_do_cadastro(al.get("perfil"))
|
||||
if perfil not in PERFIS_DO_CADASTRO:
|
||||
falta("alocacoes_desvio", f"perfil {al.get('perfil')!r} não existe no cadastro do "
|
||||
f"cliente (válidos: {', '.join(PERFIS_DO_CADASTRO)})")
|
||||
qtd = al.get("quantidade", 1)
|
||||
if not isinstance(qtd, int) or qtd <= 0:
|
||||
falta("alocacoes_desvio", f"alocação {k} ({perfil}): quantidade {qtd!r} precisa "
|
||||
"ser inteiro > 0")
|
||||
pct = al.get("percentual_alocacao", al.get("percentual"))
|
||||
if not isinstance(pct, (int, float)) or not 0 <= pct <= 100:
|
||||
falta("alocacoes_desvio", f"alocação {k} ({perfil}): percentual {pct!r} fora de 0..100")
|
||||
alocacoes.append({"perfil": perfil, "quantidade": qtd, "percentual_alocacao": pct})
|
||||
|
||||
# -- montagem do entregável ------------------------------------------------
|
||||
if not (tipo_ok and item_ok):
|
||||
continue # sem tipo/item não há código, time-box nem tarifa para montar
|
||||
|
||||
chave = (item, tipo)
|
||||
seq[chave] = seq.get(chave, 0) + 1
|
||||
codigo = f"{proj}-SD{num}-{item.replace('-', '')}-{ABREV_TIPO[tipo]}-{seq[chave]}"
|
||||
|
||||
ini, fim = como_iso(a), como_iso(b)
|
||||
if ini:
|
||||
datas_ini.append(ini)
|
||||
if fim:
|
||||
datas_fim.append(fim)
|
||||
|
||||
ent = {
|
||||
"n": n,
|
||||
"codigo": codigo,
|
||||
"titulo": nome or None,
|
||||
"tipo_entrega": tipo,
|
||||
"item": item,
|
||||
"ordem_servico": {"cadeia": None, "linha": linha},
|
||||
"data_inicio": ini,
|
||||
"data_prevista_termino": fim,
|
||||
"numero_semanas": semanas,
|
||||
"status": status_ent,
|
||||
# Derivados pelo importador a partir de datas, item e tipo — ver
|
||||
# docstring do módulo. As chaves ficam; o valor é null de propósito.
|
||||
"horas_semanais": None,
|
||||
"ust": None,
|
||||
"valor_unitario_ust": None,
|
||||
"valor_entregavel": None,
|
||||
"memoria_calculo": None,
|
||||
"alocacoes": alocacoes if alocacoes is not None else alocacoes_do_tipo(itens_canon, tipo),
|
||||
"backlog": [{"ordem": k, "descricao": _texto(x), "status": "Pendente"}
|
||||
for k, x in enumerate(e.get("atividades") or [], 1)],
|
||||
"criterios_aceite": [{"ordem": k, "descricao": _texto(c), "status": "Pendente"}
|
||||
for k, c in enumerate(e.get("criterios_aceite") or [], 1)],
|
||||
"documentacao": artefatos,
|
||||
}
|
||||
if e.get("resumo"):
|
||||
ent["descricao"] = _texto(e["resumo"])
|
||||
if e.get("paralelo_com"):
|
||||
ent["paralelo_com"] = e["paralelo_com"]
|
||||
if e.get("sprint"):
|
||||
ent["sprint"] = e["sprint"]
|
||||
|
||||
srv = {"codigo": e.get("servico")} if e.get("servico") else None
|
||||
if srv:
|
||||
srv["nome_interno"] = e.get("nome")
|
||||
if e.get("discriminador"):
|
||||
srv["discriminador"] = e["discriminador"]
|
||||
if e.get("justificativa_enquadramento"):
|
||||
srv["justificativa_enquadramento"] = _texto(e["justificativa_enquadramento"])
|
||||
ent["_servico"] = srv
|
||||
|
||||
entregaveis.append(ent)
|
||||
itens_da_sd.add(item)
|
||||
|
||||
inicio = min(datas_ini) if datas_ini else None
|
||||
fim_sd = max(datas_fim) if datas_fim else None
|
||||
prazo = None
|
||||
if inicio and fim_sd:
|
||||
# NÃO use semanas_por_datas() aqui. Isto é duração de CALENDÁRIO da SD
|
||||
# inteira, decimal de propósito — grandeza diferente das semanas
|
||||
# faturáveis do entregável. Com paralelismo os dois divergem muito: a
|
||||
# P2·SD8 tem 26 semanas de time-box em 4,6 de calendário, e o 4,6 está
|
||||
# citado nominalmente no schema (totais.prazo_calendario_semanas).
|
||||
# Trocar round(...,1) por max(1, ceil(...)) viraria 5 e quebraria esse
|
||||
# contrato. Já houve quem tentasse unificar as duas fórmulas.
|
||||
prazo = round((date.fromisoformat(fim_sd) - date.fromisoformat(inicio)).days / 7, 1)
|
||||
|
||||
projeto_txt = str(sd.get("projeto") or "")
|
||||
proj_nome = projeto_txt.split("·", 1)[1].strip() if "·" in projeto_txt else projeto_txt
|
||||
|
||||
# -- corpo ------------------------------------------------------------------
|
||||
objetivo = secao(corpo, "1")
|
||||
if not objetivo:
|
||||
pend.append(Pendencia("corpo", "objetivo",
|
||||
"a Seção 1 (Objetivo) do SD.md está vazia ou não existe — "
|
||||
"sd.objetivo é obrigatório na carga (coluna NOT NULL)"))
|
||||
contexto = secao(corpo, "2")
|
||||
enquadr = secao(corpo, "5")
|
||||
fora = secao(corpo, "6")
|
||||
|
||||
bloco_sd = {
|
||||
"codigo": sd_id,
|
||||
"numero_sequencial": int(num) if num else None,
|
||||
"rotulo_exibicao": f"SD{num} · {proj} · {titulo}",
|
||||
"nome": titulo or None,
|
||||
"projeto": {"codigo": proj, "nome": proj_nome},
|
||||
"po_responsavel": {"nome": po or None},
|
||||
"itens_contratuais": sorted(itens_da_sd),
|
||||
"inicio": inicio,
|
||||
"fim": fim_sd,
|
||||
"status": status_sd,
|
||||
}
|
||||
if sd.get("subtitulo"):
|
||||
bloco_sd["subtitulo"] = sd["subtitulo"]
|
||||
if sd.get("frente"):
|
||||
f = str(sd["frente"])
|
||||
cod, _, nome_f = f.partition("·")
|
||||
bloco_sd["frente"] = {"codigo": cod.strip(), "nome": nome_f.strip() or cod.strip()}
|
||||
|
||||
bloco_sd["objetivo"] = objetivo
|
||||
if contexto:
|
||||
# Só o conteúdo original da seção 2 — sem espelhar o Enquadramento no TR.
|
||||
# O Enquadramento vai apenas na forma estruturada, em
|
||||
# _governanca.enquadramento_tr.
|
||||
bloco_sd["contexto"] = contexto
|
||||
if fora:
|
||||
bloco_sd["escopo_nao_contemplado"] = fora
|
||||
if sd.get("resumo_executivo"):
|
||||
bloco_sd["resumo_executivo"] = _texto(sd["resumo_executivo"])
|
||||
|
||||
formal = sd.get("formalizacao") or {}
|
||||
if not isinstance(formal, dict):
|
||||
formal = {}
|
||||
sei = formal.get("processo_sei")
|
||||
if isinstance(sei, str) and not re.search(r"\d", sei):
|
||||
sei = None # "PENDENTE — não consta" não é número de processo
|
||||
|
||||
gov = {
|
||||
"estado_sd": estado,
|
||||
"versao_documento": versao or None,
|
||||
"data_abertura": data_abertura,
|
||||
"formalizacao": {"processo_sei": sei, "os_mae": formal.get("os_mae") or {}},
|
||||
}
|
||||
# `enquadr` é o recorte que secao() já fez acima. Emitido só quando há
|
||||
# tabela: a P2·SD10 tem a seção vazia e a P4·SD20 a escreve em prosa livre,
|
||||
# e nas duas a chave simplesmente não vai.
|
||||
try:
|
||||
blocos_tr = enquadramento_tr(enquadr, itens_canon["itens"])
|
||||
except ValueError as exc:
|
||||
blocos_tr = []
|
||||
pend.append(Pendencia("corpo", "secao5", f"{exc} Corrija a Seção 5 no SD.md e envie de novo."))
|
||||
if blocos_tr:
|
||||
gov["enquadramento_tr"] = blocos_tr
|
||||
dep = sd.get("dependencias") or {}
|
||||
queixa_dep = checar_dependencias(dep) if dep else None
|
||||
if queixa_dep:
|
||||
pend.append(Pendencia("sd", "dependencias", queixa_dep + " Corrija no SD.md e envie de novo."))
|
||||
dep = {}
|
||||
if dep:
|
||||
gov["dependencias"] = {k: dep.get(k) or [] for k in ("ses_mg", "isis", "terceiros")}
|
||||
if sd.get("bloqueadores"):
|
||||
gov["bloqueadores"] = [bloqueador_como_texto(b) for b in sd["bloqueadores"]]
|
||||
if sd.get("aceites_documento"):
|
||||
gov["aceites_documento"] = sd["aceites_documento"]
|
||||
|
||||
# `totais` é só sanidade para o importador (§9), e a UST agora é dele. As
|
||||
# chaves ficam pela estrutura; `valor_total`, que já era condicional, não vai.
|
||||
totais = {
|
||||
"ust_total": None,
|
||||
"ust_por_item": {},
|
||||
"prazo_calendario_semanas": prazo,
|
||||
"moeda": "BRL",
|
||||
}
|
||||
if prazo is None:
|
||||
# Removido DEPOIS de montado, e não montado condicionalmente, para não
|
||||
# mexer na ordem das chaves: `moeda` passaria à frente de
|
||||
# `prazo_calendario_semanas` e os JSONs mudariam de bytes.
|
||||
del totais["prazo_calendario_semanas"]
|
||||
|
||||
return {"$schema": "../_template/sd-schema.json", "sd": bloco_sd,
|
||||
"entregaveis": entregaveis, "totais": totais, "_governanca": gov}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def main() -> int:
|
||||
# O resumo usa '→' (U+2192), que não existe em cp1252. Com stdout
|
||||
# redirecionado o Python cai no cp1252 do locale e o print estoura DEPOIS
|
||||
# de o JSON já estar em disco. errors="replace" garante que relatar nunca
|
||||
# derruba o que já funcionou.
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
ap = argparse.ArgumentParser(
|
||||
description="Exporta SD.md para o JSON de carga do sistema (CONTRATO-JSON-V2). "
|
||||
"SD com pendência não gera arquivo: as pendências saem em stderr.")
|
||||
ap.add_argument("caminhos", nargs="+")
|
||||
ap.add_argument("--dir", help="diretório de saída (padrão: ao lado do SD.md)")
|
||||
args = ap.parse_args()
|
||||
|
||||
canon = carregar_canonico()
|
||||
# sorted(): a ordem do glob é a do filesystem, e o relatório de lote ficava
|
||||
# irreproduzível entre execuções. Mesmo critério de caminhos.sds_reais().
|
||||
arquivos = sorted(Path(p) for c in args.caminhos for p in (glob.glob(c) or [c]))
|
||||
|
||||
falhas = []
|
||||
for f in arquivos:
|
||||
try:
|
||||
sd, corpo = ler_sd_arquivo(f)
|
||||
rotulo = str(sd.get("sd") or f)
|
||||
payload = montar(sd, corpo, canon)
|
||||
# Serializa numa variável ANTES de escrever: se o payload não
|
||||
# serializa, o arquivo anterior fica intacto em vez de truncado.
|
||||
texto = serializar(payload)
|
||||
destino = Path(args.dir) if args.dir else f.parent
|
||||
destino.mkdir(parents=True, exist_ok=True)
|
||||
saida = destino / f"{str(sd.get('sd', 'sd')).replace('·', '-')}.json"
|
||||
# newline="\n": sem isso o Windows traduz para CRLF e o MESMO
|
||||
# serializar() deixa de produzir os mesmos bytes em disco.
|
||||
saida.write_text(texto, encoding="utf-8", newline="\n")
|
||||
except ExportacaoReprovada as exc:
|
||||
print(f" PENDENTE {f} — {len(exc.pendencias)} pendência(s), nenhum JSON gerado:",
|
||||
file=sys.stderr)
|
||||
print(str(exc), file=sys.stderr)
|
||||
falhas.append(f)
|
||||
continue
|
||||
except Exception as exc:
|
||||
print(f" ERRO {f}: {exc}", file=sys.stderr)
|
||||
falhas.append(f)
|
||||
continue
|
||||
print(f" {rotulo} → {saida} {len(payload['entregaveis'])} entregável(is) "
|
||||
f"({', '.join(payload['sd']['itens_contratuais'])})")
|
||||
|
||||
if falhas:
|
||||
# Isolar sem nomear é pior que o estouro que substitui: o lote
|
||||
# terminaria "com sucesso" faltando SDs.
|
||||
print(f"\n {len(falhas)} de {len(arquivos)} SD(s) sem JSON:", file=sys.stderr)
|
||||
for f in falhas:
|
||||
print(f" · {f}", file=sys.stderr)
|
||||
return 1 if falhas else 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,519 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
regras_sd.py — leitura do SD.md, cálculo e dicionários do contrato que o exportador usa.
|
||||
|
||||
Por que existe: as mesmas cinco regras estavam escritas duas vezes, uma em cada
|
||||
script, e já tinham começado a divergir. A pior era a localização da Seção 5 —
|
||||
o validador perguntava `"## 5." not in corpo` e o exportador casava o regex
|
||||
`^##\\s+5\\.\\s+`. Um corpo com `## 5.Enquadramento` passava na validação e fazia
|
||||
o exportador dropar a seção em silêncio: duas respostas para a mesma pergunta,
|
||||
e a que some é justamente a seção que sustenta a SD contra reclassificação.
|
||||
|
||||
Regra deste módulo: aqui mora o CÁLCULO e a LEITURA. Quem decide a severidade
|
||||
(ERRO, AVISO, exceção) é o chamador — o validador reporta, o exportador levanta,
|
||||
e a aplicação web mostra na tela. Misturar as duas coisas foi o que produziu
|
||||
mensagens divergentes para a mesma regra.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
sys.exit("Falta pyyaml. pip install pyyaml")
|
||||
|
||||
from caminhos import ITENS as ITENS_YAML
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Canônico
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class Canonico(NamedTuple):
|
||||
"""O YAML de contrato, carregado uma vez.
|
||||
|
||||
`itens` dá a lista de itens do contrato e a matriz de perfis do TR
|
||||
4.1.1.7.1, que alimenta `alocacoes`. É o ÚNICO arquivo de dados de que o
|
||||
exportador depende. Time-box e tarifa saíram daqui em 2026-09-03: são
|
||||
tabelas do sistema de gestão, e é ele que deriva UST e valor na carga.
|
||||
|
||||
O que NÃO está aqui, de propósito: OS e linhas de OS. Elas são cadastro do
|
||||
sistema de gestão e mudam durante o ano; qualquer cópia local envelhece em
|
||||
silêncio. O exportador exige `linha_os` declarado no SD.md e só confere o
|
||||
formato — existência, item e status quem confere é o banco, na carga.
|
||||
"""
|
||||
itens: dict
|
||||
|
||||
|
||||
def carregar_canonico() -> Canonico:
|
||||
if not ITENS_YAML.exists():
|
||||
raise FileNotFoundError(f"Arquivo canônico ausente: {ITENS_YAML}")
|
||||
itens = yaml.safe_load(ITENS_YAML.read_text(encoding="utf-8"))
|
||||
return Canonico(itens)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Leitura do SD.md
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def ler_sd_texto(texto: str, *, preservar_comentarios: bool = False) -> tuple[dict, str]:
|
||||
"""Separa o frontmatter YAML da prosa. Um parser só, para os dois scripts.
|
||||
|
||||
Devolve (frontmatter, corpo). O frontmatter contém APENAS chaves do
|
||||
frontmatter: nada de `_corpo` injetado dentro dele. A versão antiga do
|
||||
validador fazia essa injeção, e ela era inofensiva enquanto o mapa só era
|
||||
lido. Deixou de ser: a aplicação web regrava esse mesmo objeto no disco, e
|
||||
um `_corpo` ali dentro escreveria o markdown inteiro como escalar YAML
|
||||
dentro do frontmatter — corrupção silenciosa de um SD.md do repositório.
|
||||
|
||||
`preservar_comentarios=True` devolve um CommentedMap do ruamel em vez de um
|
||||
dict comum. É subclasse de dict, então validar e exportar funcionam sem
|
||||
saber a diferença; o que muda é que o round-trip de escrita preserva
|
||||
comentários e ordem. Os comentários do frontmatter carregam decisão de
|
||||
contrato — ver o campo `estado` da P2·SD10, quatro linhas de correção
|
||||
datada que um safe_dump apagaria sem deixar rastro.
|
||||
"""
|
||||
linhas = texto.splitlines()
|
||||
# Split por LINHA exatamente igual a "---", nunca por substring: comentários
|
||||
# de separação (# ------) dentro do frontmatter contêm "---" e truncariam o
|
||||
# bloco silenciosamente, produzindo "a SD não tem entregáveis".
|
||||
if not linhas or linhas[0].strip() != "---":
|
||||
raise ValueError("SD sem frontmatter YAML — a primeira linha deve ser '---'")
|
||||
fim = next((i for i, l in enumerate(linhas[1:], start=1) if l.strip() == "---"), None)
|
||||
if fim is None:
|
||||
raise ValueError("frontmatter YAML não fechado — falta a linha '---' de fecho")
|
||||
|
||||
bruto = "\n".join(linhas[1:fim])
|
||||
corpo = "\n".join(linhas[fim + 1:])
|
||||
|
||||
if preservar_comentarios:
|
||||
from ruamel.yaml import YAML
|
||||
dados = YAML().load(bruto)
|
||||
else:
|
||||
dados = yaml.safe_load(bruto)
|
||||
|
||||
if not isinstance(dados, dict):
|
||||
raise ValueError("frontmatter não é um mapeamento YAML")
|
||||
return dados, corpo
|
||||
|
||||
|
||||
def ler_sd_arquivo(caminho: Path, *, preservar_comentarios: bool = False) -> tuple[dict, str]:
|
||||
return ler_sd_texto(Path(caminho).read_text(encoding="utf-8"),
|
||||
preservar_comentarios=preservar_comentarios)
|
||||
|
||||
|
||||
def secao(corpo: str, numero: str) -> str | None:
|
||||
"""Extrai uma seção '## N. Título' do corpo, sem o cabeçalho.
|
||||
|
||||
Única resposta para "esta seção existe?" e "qual é o texto dela?". O
|
||||
validador pergunta a primeira, o exportador a segunda, e antes deste módulo
|
||||
cada um usava um critério diferente.
|
||||
"""
|
||||
pad = re.compile(rf"^##\s+{re.escape(numero)}\.\s+.*$", re.M)
|
||||
m = pad.search(corpo)
|
||||
if not m:
|
||||
return None
|
||||
resto = corpo[m.end():]
|
||||
prox = re.search(r"^##\s+\d", resto, re.M)
|
||||
texto = (resto[:prox.start()] if prox else resto).strip()
|
||||
return texto or None
|
||||
|
||||
|
||||
# Títulos das seções do corpo, como o _template/SD.md os escreve. Usados só
|
||||
# quando definir_secao() precisa CRIAR a seção — uma existente mantém o título
|
||||
# que tem.
|
||||
TITULOS_SECAO = {
|
||||
"1": "Objetivo",
|
||||
"2": "Contexto e escopo da demanda",
|
||||
"3": "Quadro de entregáveis",
|
||||
"4": "Detalhamento dos entregáveis",
|
||||
"5": "Enquadramento no Termo de Referência",
|
||||
"6": "Fora de escopo",
|
||||
}
|
||||
|
||||
|
||||
def definir_secao(corpo: str, numero: str, texto: str) -> str:
|
||||
"""O inverso de secao(): devolve o corpo com a seção N valendo `texto`.
|
||||
|
||||
Se a seção existe, só o conteúdo entre o cabeçalho dela e o próximo `## N`
|
||||
muda — o cabeçalho fica como estava. Se não existe (a P2·SD10 não tem
|
||||
Seção 1: vai do título direto à 5), o cabeçalho é criado antes da primeira
|
||||
seção de número maior, ou no fim do corpo quando não há nenhuma. O mesmo
|
||||
regex de secao() localiza o cabeçalho: duas respostas para "onde fica a
|
||||
Seção N" é exatamente o defeito que este módulo existe para não repetir.
|
||||
"""
|
||||
corpo = corpo or ""
|
||||
texto = (texto or "").strip()
|
||||
pad = re.compile(rf"^##\s+{re.escape(numero)}\.\s+.*$", re.M)
|
||||
m = pad.search(corpo)
|
||||
if m:
|
||||
resto = corpo[m.end():]
|
||||
prox = re.search(r"^##\s+\d", resto, re.M)
|
||||
fim = m.end() + (prox.start() if prox else len(resto))
|
||||
return corpo[:m.end()] + "\n\n" + texto + "\n\n" + corpo[fim:].lstrip("\n")
|
||||
|
||||
cabecalho = f"## {numero}. {TITULOS_SECAO.get(numero, '')}".rstrip()
|
||||
bloco = f"{cabecalho}\n\n{texto}\n\n"
|
||||
# Antes da primeira seção de número MAIOR — mantém a ordem 1..6 do template.
|
||||
for outro in re.finditer(r"^##\s+(\d+)\.\s+.*$", corpo, re.M):
|
||||
if int(outro.group(1)) > int(numero):
|
||||
ini = outro.start()
|
||||
return corpo[:ini].rstrip("\n") + "\n\n" + bloco + corpo[ini:]
|
||||
return corpo.rstrip("\n") + "\n\n" + bloco
|
||||
|
||||
|
||||
# Rótulos da tabela da Seção 5, exatamente como o _template/SD.md os escreve.
|
||||
_ROTULO_TR = re.compile(
|
||||
r"^\|\s*\*\*(Item do TR|Descrição do item|Aderência desta SD)\*\*\s*\|(.*?)\|?\s*$")
|
||||
_CODIGO_ITEM = re.compile(r"\bI-\d{2}\b")
|
||||
_BULLET = re.compile(r"^\s{0,3}[-*]\s+(.*)$")
|
||||
_QUEBRA_HTML = re.compile(r"<br\s*/?>", re.I)
|
||||
# Marcador de lista no INÍCIO do texto: '•', ou '-'/'*' seguido de espaço.
|
||||
_MARCADOR = re.compile(r"^(?:•|[-*](?=\s))\s*")
|
||||
|
||||
|
||||
def _texto_de_celula(v: str) -> str:
|
||||
"""Uma linha só, sem o marcador de bullet que a célula às vezes traz.
|
||||
|
||||
O '*' só conta como marcador quando vem seguido de espaço — nunca o '**' de
|
||||
negrito. Um lstrip('•*- ') ingênuo comia a abertura do negrito e deixava o
|
||||
fecho órfão: os cinco bullets da P5·SD24 começam com '**Análise das
|
||||
Necessidades...**' e saíam como 'Análise das Necessidades...**'. Texto
|
||||
remendado é pior que texto ausente, porque parece certo.
|
||||
"""
|
||||
return _MARCADOR.sub("", " ".join(str(v).split())).strip()
|
||||
|
||||
|
||||
def _bullets_da_prosa(linhas: list[str]) -> list[str]:
|
||||
"""Bullets markdown de um bloco, com a indentação pendurada recolada.
|
||||
|
||||
Os bullets da Seção 5 quebram em várias linhas com recuo de dois espaços
|
||||
(ver a P7·SD31). Sem colar a continuação, cada bullet chegava truncado na
|
||||
primeira quebra de linha — e truncado no meio de uma frase de enquadramento
|
||||
é pior que ausente, porque parece completo.
|
||||
"""
|
||||
saida: list[str] = []
|
||||
atual: str | None = None
|
||||
for l in linhas:
|
||||
m = _BULLET.match(l)
|
||||
if m:
|
||||
if atual is not None:
|
||||
saida.append(_texto_de_celula(atual))
|
||||
atual = m.group(1)
|
||||
elif atual is not None:
|
||||
if l.strip() and l[:1] in (" ", "\t"):
|
||||
atual += " " + l.strip()
|
||||
else:
|
||||
# Linha em branco ou parágrafo à margem fecha a lista: em SD31 e
|
||||
# SD24 o que vem depois dos bullets é prosa de fecho ("Nenhum
|
||||
# entregável foge do item padrão..."), não aderência.
|
||||
saida.append(_texto_de_celula(atual))
|
||||
atual = None
|
||||
if atual is not None:
|
||||
saida.append(_texto_de_celula(atual))
|
||||
return [b for b in saida if b]
|
||||
|
||||
|
||||
def enquadramento_tr(texto: str | None, itens_canon: dict) -> list[dict]:
|
||||
"""A tabela da Seção 5, como dado. Uma entrada por item que a SD toca.
|
||||
|
||||
Recebe o texto JÁ recortado por secao(corpo, "5"), nunca o corpo inteiro:
|
||||
quem responde "onde fica a Seção 5?" é secao(), e um segundo ponto fazendo a
|
||||
mesma pergunta é exatamente como nasceu a divergência descrita na docstring
|
||||
deste módulo.
|
||||
|
||||
Esta é a ÚNICA saída da Seção 5. Houve uma fase em que ela também subia como
|
||||
texto cru colado em `sd.contexto` — o conteúdo chegava, o dado não; esse
|
||||
espelho foi removido, e `sd.contexto` hoje recebe só a Seção 2. O schema já
|
||||
reservava `_governanca.enquadramento_tr` para a forma estruturada desde a
|
||||
v2.0.
|
||||
|
||||
O acervo escreve a aderência de duas maneiras, e as duas são aceitas:
|
||||
· bullets '•' dentro da própria célula, separados por <br> (P2·SD8);
|
||||
· a célula como ponteiro ("ver bullets abaixo") e os bullets em prosa
|
||||
logo depois da tabela (P7·SD31, P7·SD32 e as demais).
|
||||
|
||||
Não julga o TAMANHO da lista. Quem reprova aderência fora de 3..5 é o
|
||||
schema, e o número real precisa aparecer no JSON para o defeito ser visível:
|
||||
a P7·SD32 tem um bloco com 2 bullets, e emitir 2 é o que faz alguém escrever
|
||||
o terceiro. Bloco sem aderência nenhuma sai com lista vazia, de propósito —
|
||||
omitir a entrada seria dropar em silêncio a seção que sustenta a SD.
|
||||
"""
|
||||
if not texto:
|
||||
return []
|
||||
|
||||
linhas = texto.splitlines()
|
||||
aberturas = [i for i, l in enumerate(linhas)
|
||||
if (m := _ROTULO_TR.match(l.strip())) and m.group(1) == "Item do TR"]
|
||||
if not aberturas:
|
||||
# Seção 5 sem a tabela: a P4·SD20 é prosa livre com `###`, e tem duas
|
||||
# tabelas de OUTRO assunto que o recorte de secao() traz junto. Ancorar
|
||||
# em "Item do TR" — e não em "linha de tabela" — é o que as ignora.
|
||||
return []
|
||||
|
||||
saida = []
|
||||
for ini, fim in zip(aberturas, aberturas[1:] + [len(linhas)]):
|
||||
bloco = linhas[ini:fim]
|
||||
campos: dict[str, str] = {}
|
||||
for l in bloco:
|
||||
m = _ROTULO_TR.match(l.strip())
|
||||
if m and m.group(1) not in campos:
|
||||
campos[m.group(1)] = " ".join(m.group(2).split())
|
||||
|
||||
cod = _CODIGO_ITEM.search(campos.get("Item do TR", ""))
|
||||
if not cod:
|
||||
continue
|
||||
item = cod.group(0)
|
||||
if item not in itens_canon:
|
||||
raise ValueError(
|
||||
f"Seção 5: item {item!r} na linha `Item do TR` não existe no contrato "
|
||||
f"canônico (válidos: {', '.join(sorted(itens_canon))}). "
|
||||
"Ver clientes/ses-mg/contrato/itens.yaml")
|
||||
|
||||
entrada = {
|
||||
"item": item,
|
||||
# Derivado, nunca transcrito: a célula funde código e título, e o
|
||||
# acervo tem três formatos para ela — com aspas, sem aspas, e com
|
||||
# negrito mais CATMAS pelo meio. O título literal mora no canônico,
|
||||
# e o próprio schema diz "LITERAL de contrato/itens.yaml".
|
||||
"titulo_literal": itens_canon[item]["titulo_literal"],
|
||||
}
|
||||
if campos.get("Descrição do item"):
|
||||
entrada["descricao_item"] = campos["Descrição do item"]
|
||||
|
||||
celula = campos.get("Aderência desta SD", "")
|
||||
partes = [_texto_de_celula(p) for p in _QUEBRA_HTML.split(celula)]
|
||||
partes = [p for p in partes if p]
|
||||
if len(partes) >= 2:
|
||||
ader = partes # bullets dentro da célula
|
||||
else:
|
||||
ader = _bullets_da_prosa(bloco) or partes
|
||||
entrada["aderencia"] = ader
|
||||
|
||||
saida.append(entrada)
|
||||
return saida
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Conversões
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def como_iso(v) -> str | None:
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, date):
|
||||
return v.isoformat()
|
||||
return str(v)
|
||||
|
||||
|
||||
def como_data(v) -> date | None:
|
||||
"""Converte para date, ou levanta ValueError. Quem reporta é o chamador.
|
||||
|
||||
Sem efeito colateral de propósito: a versão antiga do validador escrevia o
|
||||
ERRO no relatório de dentro da conversão, o que a tornava inutilizável fora
|
||||
dele. O exportador precisava da mesma conversão e acabou com a sua própria.
|
||||
"""
|
||||
if v is None:
|
||||
return None
|
||||
if isinstance(v, date):
|
||||
return v
|
||||
return date.fromisoformat(str(v))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regras derivadas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def semanas_por_datas(inicio: date, fim: date) -> int:
|
||||
"""Semanas faturáveis entre duas datas — a MESMA regra do sistema de destino.
|
||||
|
||||
max(1, ceil(dias/7)), com `dias` exclusivo (subtração de datas, como a
|
||||
diferença de getTime() no sistema): 0-7d → 1, 8-14d → 2, 15-21d → 3,
|
||||
22-28d → 4.
|
||||
|
||||
NÃO serve para prazo_calendario_semanas: aquele é duração de CALENDÁRIO,
|
||||
decimal de propósito — a P2·SD8 tem 26 semanas de time-box em 4,6 de
|
||||
calendário, valor que o schema cita nominalmente. Grandezas diferentes,
|
||||
fórmulas diferentes, de caso pensado. Já houve quem tentasse unificar as
|
||||
duas; ver o comentário no cálculo do prazo em exporta_sd.montar().
|
||||
"""
|
||||
return max(1, math.ceil((fim - inicio).days / 7))
|
||||
|
||||
|
||||
def conferir_semanas(e: dict, ini: str | None, fim: str | None) -> tuple[str, str] | None:
|
||||
"""Confere as semanas DECLARADAS contra as datas.
|
||||
|
||||
Devolve (severidade, mensagem) ou None se estiver coerente. Severidade
|
||||
'DIVERGE' = erro comprovado (declarado ≠ calculado); 'SEM-DATA' = não deu
|
||||
para conferir. Só a primeira reprova em --estrito.
|
||||
|
||||
`numero_semanas` é o único fator de duração declarado à mão — no schema não
|
||||
tem x-derivado, é integer de 1 a 4. Só que o sistema de destino RECALCULA as
|
||||
semanas a partir das datas na importação, por max(1, ceil(dias/7)). Quando
|
||||
os dois discordam, a UST e o valor faturado do entregável divergem do que o
|
||||
sistema vai calcular, e ninguém percebe: 4 dos 45 entregáveis datados
|
||||
divergiam, com oscilação de -R$ 22 mil a +R$ 43 mil POR LINHA (o líquido de
|
||||
+R$ 20 mil esconde isso, e faturamento é por linha, não por saldo).
|
||||
|
||||
Só CONFERE, nunca corrige, e é de propósito: semanas é dado declarado, e o
|
||||
teto de 4 da R4 vem de documento formalizado (P2·SD12 V2). A P2·SD8 n8 tem
|
||||
31 dias, que dariam 5 — derivar emitiria JSON fora do próprio schema
|
||||
(numero_semanas.maximum = 4). Reconciliar é decisão humana, não do script.
|
||||
|
||||
Vive aqui, e não no exportador, porque a conferência precisa acontecer na
|
||||
Fase 1: enquanto ela só rodava na exportação, a divergência aparecia DEPOIS
|
||||
de o usuário já ter confirmado os dados.
|
||||
"""
|
||||
dec = e.get("semanas")
|
||||
if not (ini and fim):
|
||||
# Sem data não há o que conferir. Reportar como não verificável em vez
|
||||
# de assumir 1: a P4·SD20 tem cinco entregáveis assim, e silenciar aqui
|
||||
# carimbaria como conferido o que ninguém conferiu.
|
||||
return ("SEM-DATA",
|
||||
f"entregável {e.get('n')}: sem datas — semanas declaradas ({dec}) não verificáveis")
|
||||
try:
|
||||
a, b = date.fromisoformat(str(ini)), date.fromisoformat(str(fim))
|
||||
except ValueError:
|
||||
return ("SEM-DATA",
|
||||
f"entregável {e.get('n')}: datas ilegíveis ({ini!r} → {fim!r}) — não verificáveis")
|
||||
can = semanas_por_datas(a, b)
|
||||
if can != dec:
|
||||
return ("DIVERGE",
|
||||
f"entregável {e.get('n')}: {(b - a).days} dias => {can} semanas pela regra do "
|
||||
f"sistema, mas o SD declara {dec} — UST e valor vão divergir na importação")
|
||||
return None
|
||||
|
||||
|
||||
def checar_dependencias(dep) -> str | None:
|
||||
"""`dependencias` precisa ser um mapa por dono. Devolve a queixa, ou None.
|
||||
|
||||
A P4·SD20 trazia uma lista solta aqui e derrubava o exportador com um
|
||||
AttributeError sem dono. Uma mensagem só para os dois scripts: antes o
|
||||
validador dizia uma coisa e o exportador outra sobre a mesma regra.
|
||||
"""
|
||||
if dep is None or isinstance(dep, dict):
|
||||
return None
|
||||
return (f"`dependencias` precisa ser um mapa com ses_mg/isis/terceiros, não "
|
||||
f"{type(dep).__name__}. Sem a separação por dono não dá para dizer "
|
||||
"quem tem poder de veto sobre o cronograma. "
|
||||
"Ver clientes/ses-mg/projetos/_template/SD.md")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Contrato da carga — CONTRATO-JSON-V2.md (`npm run import:sd`, epic 92)
|
||||
# ---------------------------------------------------------------------------
|
||||
# O que está abaixo é o que a carga do sistema de gestão exige do JSON, escrito
|
||||
# uma vez para o exportador e para quem mais quiser conferir. Grafia EXATA, com
|
||||
# acento e na caixa indicada: a carga não normaliza ("Em execucao" reprova por
|
||||
# decisão, §6). Rótulo, e não código do banco — o parser do importador traduz
|
||||
# "Em Execução" para EM_EXECUCAO; confirmado com a gestão em 2026-09-03.
|
||||
|
||||
STATUS_SD = ("Planejado", "Em andamento", "Em execução", "Entregue", "Aceito")
|
||||
|
||||
# Doze valores, no masculino desde a epic 82. "Planejado" NÃO vale para
|
||||
# entregável — o esqueleto da §11 do contrato o usa por engano.
|
||||
STATUS_ENTREGAVEL = (
|
||||
"Rascunho", "Emitido", "Em Execução", "Documentado", "Aguardando Validação",
|
||||
"Em Revisão", "Aprovado", "Glosado", "Encerrado", "Cancelado",
|
||||
"Aguardando Pagamento", "Pago",
|
||||
)
|
||||
|
||||
TIPOS_IMPORTAVEIS = ("Descoberta", "Design", "Arquitetura", "Construção")
|
||||
# I-01 é a licença SaaS: preço fixo anual, sem UST. Não entra por esta carga
|
||||
# (Regra 11 da epic 92) — está no itens.yaml para o exportador saber reprovar.
|
||||
ITENS_FORA_DA_CARGA = ("I-01",)
|
||||
# Traduzem, mas PARAM a carga na aritmética (Regra 11): não entram por JSON.
|
||||
TIPOS_QUE_PARAM_A_CARGA = ("Manutenção", "Licença")
|
||||
|
||||
# Teto da janela de um entregável, em dias — 4 semanas. Regra da carga, não do
|
||||
# sistema: janela maior tem de ser quebrada em mais de um entregável no SD.md.
|
||||
JANELA_MAXIMA_DIAS = 28
|
||||
|
||||
# O repositório mantém o estado na SD; o sistema, no entregável — e vai além do
|
||||
# aceite, até o pagamento. Estado desconhecido NÃO cai num default: o exportador
|
||||
# reporta, com esta lista como opções.
|
||||
ESTADO_SD_PARA_ENTREGAVEL = {
|
||||
"rascunho": "Rascunho",
|
||||
"planejada": "Rascunho",
|
||||
"parada": "Rascunho",
|
||||
"bloqueada": "Rascunho",
|
||||
"emitida": "Emitido",
|
||||
"em_execucao": "Em Execução",
|
||||
"documentada": "Documentado",
|
||||
"aguardando_validacao": "Aguardando Validação",
|
||||
"validado": "Aprovado",
|
||||
}
|
||||
ESTADO_SD_PARA_STATUS_SD = {
|
||||
"rascunho": "Planejado",
|
||||
"planejada": "Planejado",
|
||||
"parada": "Planejado",
|
||||
"bloqueada": "Planejado",
|
||||
"emitida": "Em andamento",
|
||||
"em_execucao": "Em andamento",
|
||||
"documentada": "Em andamento",
|
||||
"aguardando_validacao": "Em andamento",
|
||||
"validado": "Entregue", # decisão da gestão em 2026-09-03 — não "Concluído"
|
||||
}
|
||||
ESTADOS_SD = tuple(ESTADO_SD_PARA_STATUS_SD)
|
||||
|
||||
# Nomes de perfil como estão no CADASTRO do cliente no sistema de gestão. O
|
||||
# itens.yaml escreve dois deles como o TR escreve; o cadastro, não. O importador
|
||||
# traduz esses dois aliases (§4.1), mas emitir já o nome do cadastro tira a
|
||||
# dependência de uma tradução alheia. itens.yaml fica como está — é transcrição
|
||||
# do TR, e o TR é a fonte dele.
|
||||
PERFIL_ALIASES = {
|
||||
"Especialista de Inteligência (Cientistas dados/Processos)":
|
||||
"Especialista de Inteligência (Cientista de Dados)",
|
||||
"Especialista de infraestrutura": "Especialista de Infraestrutura",
|
||||
}
|
||||
PERFIS_DO_CADASTRO = (
|
||||
"Especialista de Inteligência (Cientista de Dados)",
|
||||
"Analista de Negócio/Processo",
|
||||
"Especialista de Negócio (Saúde)",
|
||||
"Especialista de Tecnologia / Arquiteto",
|
||||
"Gerente de Projeto",
|
||||
"Desenvolvedor / Engenheiro de Dados",
|
||||
"Especialista de Infraestrutura",
|
||||
"Scrum Master",
|
||||
)
|
||||
|
||||
|
||||
def perfil_do_cadastro(nome) -> str:
|
||||
"""O nome do perfil como o cadastro do cliente o conhece."""
|
||||
nome = " ".join(str(nome or "").split())
|
||||
return PERFIL_ALIASES.get(nome, nome)
|
||||
|
||||
|
||||
# Formato de `ordem_servico.linha` (CONTRATO-JSON-V2 §5): "{código-da-OS}-L{n}".
|
||||
_LINHA_OS = re.compile(r"^\d+-L\d+$")
|
||||
|
||||
|
||||
def conferir_formato_linha_os(linha) -> str | None:
|
||||
"""A queixa contra o `linha_os` declarado, ou None.
|
||||
|
||||
Só FORMATO. Existência da OS, da linha, item e status são cadastro do
|
||||
sistema de gestão e é ele que os confere na carga (resolução em dois passos
|
||||
no banco, §5) — a carga recusa nomeando o que falta e nada é gravado. Não
|
||||
há cópia local disso aqui de propósito: OS e linhas novas entram durante o
|
||||
ano, e um de-para em arquivo apontaria linha velha sem ninguém notar.
|
||||
Decisão da gestão em 2026-09-03.
|
||||
"""
|
||||
if linha in (None, ""):
|
||||
return ("`linha_os` ausente — ordem_servico.linha é obrigatório na carga e só quem "
|
||||
"emite a SD sabe qual OS a lastreia. Copie da tela da OS no sistema, no "
|
||||
"formato {OS}-L{n} (ex.: 1090-L1)")
|
||||
if not _LINHA_OS.match(str(linha).strip()):
|
||||
return f"linha de OS {linha!r} fora do formato `{{OS}}-L{{n}}` (ex.: 1090-L1)"
|
||||
return None
|
||||
|
||||
|
||||
def janela_dias(inicio: date, fim: date) -> int:
|
||||
"""Dias entre as datas, exclusivo — a mesma subtração que a carga faz."""
|
||||
return (fim - inicio).days
|
||||
@@ -0,0 +1,402 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
teste_e2e.py — o fluxo inteiro, do upload ao download, sem servidor rodando.
|
||||
|
||||
Usa o test client do Flask: exercita exatamente as rotas de app/servidor.py,
|
||||
sem porta aberta e sem depender de o servidor estar de pé.
|
||||
|
||||
O que esta suíte existe para provar, na ordem:
|
||||
|
||||
1. upload NUNCA baixa nada — o download só sai do /exportar, depois de um
|
||||
clique, e só quando o exportador não tem pendência;
|
||||
2. SD com pendência é recusada no servidor mesmo por POST direto, e a tela
|
||||
mostra formulário SÓ com os campos pendentes;
|
||||
3. a correção feita no formulário chega ao JSON — e o JSON é byte-idêntico
|
||||
ao do exporta_sd.py na linha de comando sobre um SD.md com a mesma
|
||||
correção;
|
||||
4. campo não pendente não é tocado por um Salvar (o formulário só manda o
|
||||
que está pendente, e o servidor só toca no que vem).
|
||||
|
||||
Roda assim:
|
||||
python3 scripts/teste_e2e.py
|
||||
0 = tudo passou; 1 = alguma checagem falhou
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import html as H
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
from pathlib import Path
|
||||
|
||||
RAIZ = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(RAIZ / "scripts"))
|
||||
sys.path.insert(0, str(RAIZ / "app"))
|
||||
|
||||
from caminhos import PROJETOS # noqa: E402
|
||||
from regras_sd import ler_sd_texto, secao # noqa: E402
|
||||
import servidor # noqa: E402
|
||||
|
||||
FALHAS: list[str] = []
|
||||
CLIENTE = servidor.app.test_client()
|
||||
|
||||
SD8 = PROJETOS / "P2-vacinacao/sds/SD8-mvp-vacina-em-dia/SD.md"
|
||||
SD10 = PROJETOS / "P2-vacinacao/sds/SD10-crie/SD.md"
|
||||
SD20 = PROJETOS / "P4-cancer-de-mama/sds/SD20-siscan-d1/SD.md"
|
||||
SD24 = PROJETOS / "P5-portal-rmds/sds/SD24-mvp-institucional/SD.md"
|
||||
SD31 = PROJETOS / "P7-assistencia-farmaceutica/sds/SD31-fundacao-da-base-de-dispensacao/SD.md"
|
||||
|
||||
|
||||
def ck(cond, desc):
|
||||
print(f" {'ok ' if cond else 'FALHA'} {desc}")
|
||||
if not cond:
|
||||
FALHAS.append(desc)
|
||||
|
||||
|
||||
def post(rota, dados=None, **kw):
|
||||
return CLIENTE.post(rota, data=dados or {}, follow_redirects=True, **kw)
|
||||
|
||||
|
||||
def texto(resp):
|
||||
return resp.get_data(as_text=True)
|
||||
|
||||
|
||||
def enviar(caminho: Path, nome: str | None = None, conteudo: bytes | None = None):
|
||||
dados = {"arquivo": (BytesIO(conteudo if conteudo is not None else caminho.read_bytes()),
|
||||
nome or caminho.name)}
|
||||
return post("/upload", dados, content_type="multipart/form-data")
|
||||
|
||||
|
||||
def pagina():
|
||||
return texto(CLIENTE.get("/revisar", follow_redirects=True))
|
||||
|
||||
|
||||
def reiniciar():
|
||||
post("/reiniciar")
|
||||
|
||||
|
||||
def campos_do_form(pag: str) -> dict:
|
||||
"""Relê o formulário de pendências como o navegador o reenviaria."""
|
||||
campos = {}
|
||||
for m in re.finditer(r'<input[^>]*name="([^"]+)"[^>]*>', pag):
|
||||
tag = m.group(0)
|
||||
if any(t in tag for t in ('type="radio"', 'type="checkbox"', 'type="file"')):
|
||||
continue
|
||||
v = re.search(r'value="([^"]*)"', tag)
|
||||
campos[m.group(1)] = H.unescape(v.group(1)) if v else ""
|
||||
for m in re.finditer(r'<textarea[^>]*name="([^"]+)"[^>]*>(.*?)</textarea>', pag, re.S):
|
||||
campos[m.group(1)] = H.unescape(m.group(2))
|
||||
for m in re.finditer(r'<select[^>]*name="([^"]+)"[^>]*>(.*?)</select>', pag, re.S):
|
||||
sel = re.search(r'<option value="([^"]*)"[^>]*selected', m.group(2))
|
||||
campos[m.group(1)] = H.unescape(sel.group(1)) if sel else ""
|
||||
return campos
|
||||
|
||||
|
||||
def estado_na_tela(pag: str) -> str:
|
||||
"""O estado que a tela DECLARA ("estado: <strong>…</strong>"). A trilha do
|
||||
cabeçalho repete os nomes de todos os estados, então procurar o texto solto
|
||||
daria positivo sempre."""
|
||||
m = re.search(r"estado: <strong>([^<]+)</strong>", pag)
|
||||
return m.group(1) if m else ""
|
||||
|
||||
|
||||
def baixou(resp) -> bool:
|
||||
return "attachment" in resp.headers.get("Content-Disposition", "")
|
||||
|
||||
|
||||
def exportar_pela_cli(sd_md: Path) -> str:
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
r = subprocess.run([sys.executable, str(RAIZ / "scripts/exporta_sd.py"), str(sd_md),
|
||||
"--dir", tmp], capture_output=True, text=True, encoding="utf-8")
|
||||
saidas = list(Path(tmp).glob("*.json"))
|
||||
if not saidas:
|
||||
return f"(CLI não gerou JSON: {r.stderr.strip()})"
|
||||
return saidas[0].read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
def preencher_linhas(pag: str, linha: str = "1090-L1") -> str:
|
||||
"""Responde no formulário todas as pendências de `linha_os` com a mesma
|
||||
linha — o que a pessoa faria copiando da tela da OS. Devolve a página."""
|
||||
c = campos_do_form(pag)
|
||||
for k in c:
|
||||
if k.endswith("__linha_os"):
|
||||
c[k] = linha
|
||||
return texto(post("/revisar", c))
|
||||
|
||||
|
||||
def sd_pronta():
|
||||
print("\n1. SD31: só falta a linha de OS; preenchida, o clique exporta — e nada sai antes dele")
|
||||
reiniciar()
|
||||
r = enviar(SD31)
|
||||
ck(not baixou(r), "o upload NÃO devolve download — devolve a tela de revisão")
|
||||
pag = texto(r)
|
||||
ck(estado_na_tela(pag) == "Com pendências" and "4 pendência(s)" in pag,
|
||||
"estado: com pendências — as 4 linhas de OS que o SD.md não declara")
|
||||
c = campos_do_form(pag)
|
||||
ck(set(c) == {f"e{i}__linha_os" for i in range(4)},
|
||||
f"o formulário traz SÓ os quatro campos de linha de OS, como texto ({sorted(c)})")
|
||||
ck("<select" not in pag, "sem select de candidatas: não há de-para local, a pessoa copia da tela da OS")
|
||||
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada enquanto falta a linha")
|
||||
|
||||
pag = preencher_linhas(pag)
|
||||
ck(estado_na_tela(pag) == "Pronta para exportar", "preenchidas, estado: pronta para exportar")
|
||||
ck("PENDENTE" not in pag and 'id="form-pendencias"' not in pag,
|
||||
"sem pendência não há formulário nenhum na tela")
|
||||
ck("1090-L1" in pag, "a linha de OS declarada aparece no resumo")
|
||||
ck("Exportar JSON" in pag and "disabled" not in pag.split("Exportar JSON")[0][-200:],
|
||||
"o botão Exportar está habilitado")
|
||||
|
||||
r = post("/exportar")
|
||||
ck(baixou(r) and "P7-SD31.json" in r.headers.get("Content-Disposition", ""), "download entregue")
|
||||
baixado = texto(r)
|
||||
p = json.loads(baixado)
|
||||
ck(p["sd"]["codigo"] == "P7·SD31", "é o JSON da SD certa")
|
||||
ck(all(e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]),
|
||||
"todo entregável saiu com a linha de OS preenchida")
|
||||
ck(p["sd"]["status"] == "Planejado" and all(e["status"] == "Rascunho" for e in p["entregaveis"]),
|
||||
"status nos rótulos do contrato")
|
||||
ck(estado_na_tela(pagina()) == "JSON exportado", "a tela registra a exportação")
|
||||
|
||||
# A mesma régua da CLI: grava o SD.md corrigido (Salvar SD.md) e exporta
|
||||
# esse arquivo pela linha de comando — tem de dar os mesmos bytes.
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
alvo = Path(tmp) / "SD.md"
|
||||
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
|
||||
ck(alvo.exists() and alvo.read_text(encoding="utf-8").count("linha_os: 1090-L1") == 4,
|
||||
"o SD.md gravado leva as quatro linhas declaradas")
|
||||
ck(baixado == exportar_pela_cli(alvo), "bytes idênticos aos do exporta_sd.py sobre o SD.md gravado")
|
||||
|
||||
|
||||
def pendencia_de_semanas():
|
||||
print("\n2. SD24: semanas declaradas ≠ datas → pendente; corrigido no formulário, exporta")
|
||||
reiniciar()
|
||||
pag = texto(enviar(SD24))
|
||||
ck(estado_na_tela(pag) == "Com pendências", "estado: com pendências")
|
||||
ck("18 dias" in pag and "3 semana" in pag and "declara 1" in pag,
|
||||
"a pendência diz os dois números — derivado e declarado — e aponta o entregável")
|
||||
c = campos_do_form(pag)
|
||||
ck(set(c) == {"e3__semanas", "e3__inicio", "e3__fim"},
|
||||
f"o formulário traz SÓ o entregável pendente: semanas + as datas dele ({sorted(c)})")
|
||||
|
||||
r = post("/exportar")
|
||||
ck(not baixou(r) and "Exportação recusada" in texto(r),
|
||||
"POST /exportar direto é recusado pelo servidor, com a razão")
|
||||
|
||||
c["e3__semanas"] = "3"
|
||||
pag = texto(post("/revisar", c))
|
||||
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidas as semanas, a SD fica pronta")
|
||||
p = json.loads(texto(post("/exportar")))
|
||||
e4 = p["entregaveis"][3]
|
||||
ck(e4["numero_semanas"] == 3 and e4["ust"] is None and e4["valor_entregavel"] is None,
|
||||
"o JSON leva 3 semanas; UST e valor saem null para o importador derivar")
|
||||
ck(p["sd"]["nome"] == "MVP Institucional e Protótipos do Portal RMDS",
|
||||
"o título — que NÃO estava no formulário — continua intacto")
|
||||
|
||||
# Apagar a declaração também resolve: em branco, deriva.
|
||||
reiniciar()
|
||||
c = campos_do_form(texto(enviar(SD24)))
|
||||
c["e3__semanas"] = ""
|
||||
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
|
||||
"semanas em branco = 'derive das datas' — também destranca")
|
||||
|
||||
|
||||
def teto_de_28_dias():
|
||||
print("\n3. SD8: janela de 31 dias e duas divergências de semanas")
|
||||
reiniciar()
|
||||
pag = texto(enviar(SD8))
|
||||
ck("3 pendência(s)" in pag, "três pendências")
|
||||
ck("31 dias" in pag and "teto de 28 dias" in pag, "o teto de 28 dias é nomeado, com os dias reais")
|
||||
c = campos_do_form(pag)
|
||||
ck(set(c) == {"e1__semanas", "e1__inicio", "e1__fim", "e2__semanas", "e2__inicio", "e2__fim",
|
||||
"e7__inicio", "e7__fim"},
|
||||
f"formulário só dos entregáveis 2, 3 e 8 — janela inteira de cada um ({sorted(c)})")
|
||||
c["e1__semanas"] = "2"
|
||||
c["e2__semanas"] = "1"
|
||||
c["e7__fim"] = "2026-06-30" # 28 dias → 4 semanas, o que o SD já declarava
|
||||
pag = texto(post("/revisar", c))
|
||||
ck(estado_na_tela(pag) == "Pronta para exportar", "corrigidos os três, a SD fica pronta")
|
||||
p = json.loads(texto(post("/exportar")))
|
||||
e8 = p["entregaveis"][7]
|
||||
ck(e8["data_prevista_termino"] == "2026-06-30" and e8["numero_semanas"] == 4 and e8["ust"] is None,
|
||||
"o entregável 8 saiu com a data corrigida e 4 semanas; a UST é do importador")
|
||||
ck(p["entregaveis"][1]["numero_semanas"] == 2 and p["entregaveis"][2]["numero_semanas"] == 1,
|
||||
"os entregáveis 2 e 3 saíram com as semanas das datas")
|
||||
ck(p["sd"]["status"] == "Em andamento" and e8["status"] == "Documentado",
|
||||
"estado `documentada` → sd.status 'Em andamento', entregável 'Documentado'")
|
||||
ck(p["entregaveis"][0]["alocacoes"][0]["perfil"] == "Especialista de Inteligência (Cientista de Dados)",
|
||||
"o alias do perfil saiu traduzido para o nome do cadastro")
|
||||
|
||||
|
||||
def po_e_objetivo():
|
||||
print("\n4. SD10: PO em branco e Seção 1 ausente → formulário com os dois; corrigidos, exporta")
|
||||
reiniciar()
|
||||
pag = texto(enviar(SD10))
|
||||
c = campos_do_form(pag)
|
||||
ck(set(c) == {"sd__po_responsavel", "corpo__objetivo"} | {f"e{i}__linha_os" for i in range(5)},
|
||||
f"PO, objetivo e as cinco linhas de OS no formulário — nada mais ({sorted(c)})")
|
||||
ck("[a designar]" not in pag and "[a redigir" not in pag, "nenhum placeholder inventado na tela")
|
||||
c["sd__po_responsavel"] = "Guilherme Bernardino"
|
||||
c["corpo__objetivo"] = "Permitir à equipe do CRIE manter as regras de elegibilidade sem deploy."
|
||||
for i in range(5):
|
||||
c[f"e{i}__linha_os"] = "1090-L1" if i == 2 else "2587-L3" # n3 é I-02; os demais, I-05
|
||||
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar", "preenchidos, a SD fica pronta")
|
||||
p = json.loads(texto(post("/exportar")))
|
||||
ck(p["sd"]["po_responsavel"]["nome"] == "Guilherme Bernardino", "o PO chega ao JSON")
|
||||
ck(p["sd"]["objetivo"].startswith("Permitir à equipe do CRIE"), "o objetivo chega ao JSON")
|
||||
corpo = servidor.ATUAL["sessao"].corpo
|
||||
ck(secao(corpo, "1") is not None and corpo.index("## 1.") < corpo.index("## 5."),
|
||||
"a Seção 1 foi criada no corpo, antes da Seção 5")
|
||||
ck([e["ordem_servico"]["linha"] for e in p["entregaveis"]]
|
||||
== ["2587-L3", "2587-L3", "1090-L1", "2587-L3", "2587-L3"],
|
||||
"cada entregável saiu com a linha que a pessoa declarou")
|
||||
|
||||
|
||||
def sem_datas():
|
||||
print("\n5. SD20: sem datas, e PO 'PENDENTE' — reprovada, nada baixa")
|
||||
reiniciar()
|
||||
pag = texto(enviar(SD20))
|
||||
ck("11 pendência(s)" in pag, "onze pendências: PO + 5 × (início, fim)")
|
||||
ck("convenção do repositório" in pag, "o PO 'PENDENTE — ...' é reconhecido como não-nome")
|
||||
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada")
|
||||
|
||||
|
||||
def dado_invalido():
|
||||
print("\n6. Dado inválido vira mensagem na tela, não traceback no console")
|
||||
reiniciar()
|
||||
c = campos_do_form(texto(enviar(SD24)))
|
||||
c["e3__inicio"] = "31/13/2026"
|
||||
pag = texto(post("/revisar", c))
|
||||
ck("ilegível" in pag and "Traceback" not in pag, "data ilegível é pendência nomeada")
|
||||
c = campos_do_form(pag)
|
||||
c["e3__inicio"] = "2026-07-13"
|
||||
c["e3__fim"] = "2026-07-10"
|
||||
ck("anterior ao início" in texto(post("/revisar", c)), "fim antes do início é pendência nomeada")
|
||||
|
||||
|
||||
def entrada_ruim():
|
||||
print("\n7. Entrada ruim: uma mensagem específica para cada caso")
|
||||
for nome, conteudo, esperado, desc in (
|
||||
("notas.txt", b"nao sou markdown", "não é um arquivo .md", "extensão errada"),
|
||||
("vazio.md", b" \n", "está vazio", "arquivo vazio"),
|
||||
("semfm.md", "# só prosa\n".encode(), "primeira linha deve ser", "sem frontmatter"),
|
||||
("aberto.md", "---\nsd: X\n\n# sem fecho\n".encode(), "não fechado", "frontmatter não fechado"),
|
||||
("lista.md", "---\n- a\n- b\n---\n".encode(), "não é um mapeamento", "frontmatter que não é mapa"),
|
||||
("latin.md", "---\nsd: Vacinação\n---\n".encode("latin-1"), "não está em UTF-8", "fora de UTF-8"),
|
||||
):
|
||||
reiniciar()
|
||||
ck(esperado in texto(enviar(Path(nome), nome, conteudo)), desc)
|
||||
|
||||
reiniciar()
|
||||
pag = texto(enviar(Path("minima.md"), "minima.md",
|
||||
"---\nsd: X\nestado: voando\nentregaveis: []\n---\n# t\n".encode()))
|
||||
ck("fora da notação" in pag and "não tem entregáveis" in pag and "voando" in pag,
|
||||
"frontmatter mínimo: identificador, estado e entregáveis reprovados com nome")
|
||||
ck("Este conserto é no" in pag, "pendência estrutural aponta para o SD.md, sem controle na tela")
|
||||
|
||||
|
||||
def writeback():
|
||||
print("\n8. Salvar SD.md: correção gravada, comentários preservados, prosa reanexada")
|
||||
reiniciar()
|
||||
c = campos_do_form(texto(enviar(SD10)))
|
||||
c["sd__po_responsavel"] = "PO de teste"
|
||||
c["corpo__objetivo"] = "Objetivo de teste."
|
||||
post("/revisar", c)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
alvo = Path(tmp) / "SD.md"
|
||||
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
|
||||
ck(alvo.exists(), "arquivo gravado")
|
||||
txt = alvo.read_text(encoding="utf-8")
|
||||
ck("CORREÇÃO 17/08/2026" in txt and "protótipo navegável está EM CRIAÇÃO" in txt,
|
||||
"o comentário datado do campo `estado` sobreviveu inteiro")
|
||||
ck("_corpo" not in txt, "nenhuma chave _corpo dentro do frontmatter")
|
||||
ck('po_responsavel: PO de teste' in txt or 'po_responsavel: "PO de teste"' in txt, "o PO foi gravado")
|
||||
ck("## 1. Objetivo" in txt and "## 5. Enquadramento no Termo de Referência" in txt,
|
||||
"a Seção 1 nova e a prosa original estão no arquivo")
|
||||
d, _ = ler_sd_texto(txt)
|
||||
ck(d["po_responsavel"] == "PO de teste" and len(d["entregaveis"]) == 5,
|
||||
"o arquivo gravado reparseia com os mesmos entregáveis")
|
||||
|
||||
reiniciar()
|
||||
enviar(SD10)
|
||||
with tempfile.TemporaryDirectory() as tmp:
|
||||
alvo = Path(tmp) / "SD.md"
|
||||
post("/salvar-sd", {"destino": str(alvo)})
|
||||
ck(not alvo.exists(), "sem a caixa de confirmação marcada, nada é gravado")
|
||||
|
||||
|
||||
def linha_de_os_no_formulario():
|
||||
print("\n9. Linha de OS: só o formato é conferido aqui; existência é do banco, na carga")
|
||||
sd_md = ("---\n"
|
||||
'sd: "P9·SD99"\nprojeto: "P9 · Teste"\ntitulo: "Sintética"\n'
|
||||
'po_responsavel: "Alguém"\nversao: "V1"\nestado: rascunho\n'
|
||||
"entregaveis:\n"
|
||||
' - n: 1\n nome: "Um"\n tipo: Descoberta\n item: I-02\n'
|
||||
' linha_os: "L1"\n inicio: 2026-09-01\n fim: 2026-09-11\n'
|
||||
"---\n# T\n\n## 1. Objetivo\n\nAlgo.\n").encode("utf-8")
|
||||
reiniciar()
|
||||
pag = texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md))
|
||||
ck("fora do formato" in pag and "1090-L1" in pag, "linha mal formada: pendência que mostra o formato")
|
||||
ck('<input name="e0__linha_os"' in pag and "<select" not in pag, "campo de texto, sem lista local")
|
||||
c = campos_do_form(pag)
|
||||
c["e0__linha_os"] = "9999-L7"
|
||||
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
|
||||
"linha bem formada que o repositório não conhece PASSA — quem confere existência é o banco")
|
||||
p = json.loads(texto(post("/exportar")))
|
||||
ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "9999-L7", "o que a pessoa declarou vai no JSON")
|
||||
|
||||
reiniciar()
|
||||
c = campos_do_form(texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md)))
|
||||
c["e0__linha_os"] = ""
|
||||
pag = texto(post("/revisar", c))
|
||||
ck("`linha_os` ausente" in pag and not baixou(post("/exportar")),
|
||||
"em branco não há fallback nenhum: continua pendente e não exporta")
|
||||
ck("linha_os" not in servidor.ATUAL["sessao"].dados["entregaveis"][0],
|
||||
"e a chave `linha_os` saiu do frontmatter, em vez de virar string vazia")
|
||||
|
||||
|
||||
def editar_depois_de_exportar():
|
||||
print("\n10. Editar depois de exportar desfaz o rótulo 'exportada'")
|
||||
reiniciar()
|
||||
preencher_linhas(texto(enviar(SD31)))
|
||||
post("/exportar")
|
||||
ck(estado_na_tela(pagina()) == "JSON exportado", "exportada")
|
||||
post("/revisar", {"sd__titulo": "outro título"})
|
||||
pag = pagina()
|
||||
ck(estado_na_tela(pag) == "Pronta para exportar",
|
||||
"a edição volta a sessão para 'pronta' — o que foi exportado era outra versão")
|
||||
ck("outro título" in pag, "e a edição foi aplicada")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
sd_pronta()
|
||||
pendencia_de_semanas()
|
||||
teto_de_28_dias()
|
||||
po_e_objetivo()
|
||||
sem_datas()
|
||||
dado_invalido()
|
||||
entrada_ruim()
|
||||
writeback()
|
||||
linha_de_os_no_formulario()
|
||||
editar_depois_de_exportar()
|
||||
|
||||
print()
|
||||
if FALHAS:
|
||||
print(f"{len(FALHAS)} falha(s):")
|
||||
for f in FALHAS:
|
||||
print(" ·", f)
|
||||
return 1
|
||||
print("ponta a ponta: tudo passou.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,529 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
teste_fluxo.py — prova que o exportador cumpre o contrato da carga SEM mudar a
|
||||
estrutura do JSON, e que a aplicação web e a CLI são a MESMA régua.
|
||||
|
||||
O que está fixado aqui, na ordem em que custa mais errar:
|
||||
|
||||
1. ESTRUTURA — o JSON novo tem exatamente as chaves, na mesma ordem, do JSON
|
||||
que o importador já consome (scripts/backlog/sd/P7-SD31.json). A rotina
|
||||
de importação depende disso; só VALORES podem mudar.
|
||||
2. CONTRATO — cada SD que exporta cumpre CONTRATO-JSON-V2.md: linha de OS
|
||||
preenchida, UST = time-box × semanas(datas), valor = UST × tarifa, janela
|
||||
≤ 28 dias, vocabulário exato da §6, unicidades da §8.
|
||||
3. PENDÊNCIAS — as SDs que NÃO exportam ficam pendentes pelos motivos certos,
|
||||
campo a campo, e nenhuma outra.
|
||||
4. MESMA RÉGUA — o caminho da app (ruamel) e o da CLI (safe_load) produzem
|
||||
as mesmas pendências e, quando exportam, os mesmos bytes.
|
||||
5. As peças reaproveitadas de regras_sd continuam valendo (secao, Seção 5,
|
||||
tipos do ruamel, definir_secao).
|
||||
|
||||
Roda sem framework de teste:
|
||||
python3 scripts/teste_fluxo.py
|
||||
0 = tudo bate; 1 = alguma checagem falhou
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from datetime import date
|
||||
|
||||
from caminhos import PROJETOS, RAIZ, sds_reais
|
||||
from exporta_sd import ExportacaoReprovada, analisar, montar, serializar
|
||||
from regras_sd import (JANELA_MAXIMA_DIAS, PERFIS_DO_CADASTRO, STATUS_ENTREGAVEL, STATUS_SD,
|
||||
carregar_canonico, definir_secao, enquadramento_tr, ler_sd_arquivo,
|
||||
ler_sd_texto, secao, semanas_por_datas)
|
||||
|
||||
FALHAS: list[str] = []
|
||||
|
||||
# O que cada SD do acervo deve produzir HOJE. Chave = pasta da SD; valor = as
|
||||
# pendências esperadas como (escopo, indice, campo) — lista vazia = exporta.
|
||||
# Não é silenciamento: a suíte segue vermelha para qualquer pendência NOVA ou
|
||||
# para uma esperada que sumir sem o SD.md ter mudado.
|
||||
def _sem_linha(n: int) -> list[tuple]:
|
||||
"""SD cujos n entregáveis ainda não declaram `linha_os` no SD.md."""
|
||||
return [("entregavel", i, "linha_os") for i in range(n)]
|
||||
|
||||
|
||||
ESPERADO = {
|
||||
"SD8-mvp-vacina-em-dia": [("entregavel", 1, "semanas"), ("entregavel", 2, "semanas"),
|
||||
("entregavel", 7, "fim")],
|
||||
"SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")] + _sem_linha(5),
|
||||
"SD18-integracao-core-rnds-samu": _sem_linha(4),
|
||||
"SD20-siscan-d1": [("sd", None, "po_responsavel")]
|
||||
+ [("entregavel", i, c) for i in range(5) for c in ("inicio", "fim")],
|
||||
"SD24-mvp-institucional": [("entregavel", 3, "semanas")],
|
||||
"SD27-design-thinking-oficina-superintendencias": _sem_linha(3),
|
||||
"SD31-fundacao-da-base-de-dispensacao": _sem_linha(4),
|
||||
"SD32-extracao-automatizada-d-1-do-sigaf": _sem_linha(4),
|
||||
"SD33-painel-institucional-de-dispensacao": _sem_linha(3),
|
||||
"SD34-pacientes-ativos-e-alerta-de-inativacao": _sem_linha(3),
|
||||
"SD35-programacao-estoque-e-faltas": _sem_linha(2),
|
||||
"SD36-analise-de-processos-ceaf-com-ia-piloto": _sem_linha(3),
|
||||
}
|
||||
|
||||
# O que a pessoa escreveria hoje em `linha_os`, copiado da tabela §5 do contrato
|
||||
# (banco de 2026-09-03). NÃO é de-para do exportador — ele não tem nenhum, por
|
||||
# decisão da gestão. Serve só para os testes de contrato e de estrutura rodarem
|
||||
# sobre um payload completo, como se a SD já estivesse preenchida.
|
||||
LINHA_DE_TESTE = {"I-02": "1090-L1", "I-03": "2587-L1", "I-04": "2587-L2", "I-05": "2587-L3"}
|
||||
|
||||
|
||||
def com_linhas(sd: dict) -> dict:
|
||||
"""A SD como ficaria depois de a pessoa preencher `linha_os` no formulário."""
|
||||
for e in sd.get("entregaveis") or []:
|
||||
if isinstance(e, dict) and not e.get("linha_os"):
|
||||
e["linha_os"] = LINHA_DE_TESTE.get(e.get("item"), "1090-L1")
|
||||
return sd
|
||||
|
||||
# Regras do sd-schema.json que NÃO são do contrato da carga — a decisão da
|
||||
# gestão (2026-09-03) é deixar o importador rejeitar texto fora do teto, e a
|
||||
# régua de 3..5 bullets de aderência é redação do repositório, não da carga.
|
||||
# O schema continua sendo conferido aqui, por estrutura; estas ficam nomeadas.
|
||||
SCHEMA_CONHECIDO = {
|
||||
"SD32-extracao-automatizada-d-1-do-sigaf":
|
||||
"o bloco I-02 da Seção 5 tem 2 bullets de aderência e o schema pede 3..5 — regra de "
|
||||
"redação do repositório, fora do contrato da carga. O exportador não bloqueia por ela.",
|
||||
}
|
||||
|
||||
|
||||
def checar(condicao: bool, descricao: str) -> None:
|
||||
print(f" {'ok ' if condicao else 'FALHA'} {descricao}")
|
||||
if not condicao:
|
||||
FALHAS.append(descricao)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1. Estrutura
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _esqueleto(o, pref=""):
|
||||
"""Todas as listas de chaves do JSON, por caminho — ordem incluída."""
|
||||
saida = []
|
||||
if isinstance(o, dict):
|
||||
saida.append((pref, list(o.keys())))
|
||||
for k, v in o.items():
|
||||
saida += _esqueleto(v, f"{pref}/{k}")
|
||||
elif isinstance(o, list) and o:
|
||||
saida += _esqueleto(o[0], pref + "[]")
|
||||
return saida
|
||||
|
||||
|
||||
def _achatar(o, p=""):
|
||||
if isinstance(o, dict):
|
||||
for k, v in o.items():
|
||||
yield from _achatar(v, f"{p}/{k}")
|
||||
elif isinstance(o, list):
|
||||
for i, v in enumerate(o):
|
||||
yield from _achatar(v, f"{p}[{i}]")
|
||||
else:
|
||||
yield p, o
|
||||
|
||||
|
||||
# O que o contrato PEDIU que mudasse de valor. Qualquer outro campo diferente
|
||||
# entre o JSON antigo e o novo é regressão na promessa "estrutura e valores
|
||||
# iguais, só o que o contrato exige muda".
|
||||
VALORES_QUE_O_CONTRATO_MUDOU = re.compile(
|
||||
r"/entregaveis\[\d+\]/(ordem_servico/linha|alocacoes\[\d+\]/perfil"
|
||||
r"|horas_semanais|ust|valor_unitario_ust|valor_entregavel|memoria_calculo)$"
|
||||
r"|^/totais/(ust_total|ust_por_item(/.*)?|valor_total)$")
|
||||
|
||||
# Chaves cuja PRESENÇA já era condicional no exportador antigo, ou que dependem
|
||||
# do conteúdo: comparar a lista delas contra um JSON antigo não diz nada sobre
|
||||
# estrutura. `valor_total` só saía com preço em todos os itens; `ust_por_item`
|
||||
# tem uma chave por item da SD — hoje sai {} porque a UST é do importador.
|
||||
CAMINHOS_DE_CONTEUDO = ("/totais", "/totais/ust_por_item")
|
||||
|
||||
|
||||
def estrutura_intacta(canon) -> None:
|
||||
print("\n· ESTRUTURA: o JSON novo tem as mesmas chaves, na mesma ordem, do que o importador já lê")
|
||||
pares = 0
|
||||
for f in sds_reais():
|
||||
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
|
||||
ref_path = RAIZ / "scripts/backlog/sd" / (str(sd.get("sd", "")).replace("·", "-") + ".json")
|
||||
if not ref_path.exists() or analisar(com_linhas(sd), corpo, canon)[1]:
|
||||
continue # sem JSON antigo para comparar, ou pendente por outro motivo
|
||||
pares += 1
|
||||
referencia = json.loads(ref_path.read_text(encoding="utf-8"))
|
||||
novo = json.loads(serializar(montar(sd, corpo, canon)))
|
||||
ref, atual = dict(_esqueleto(referencia)), dict(_esqueleto(novo))
|
||||
for c in CAMINHOS_DE_CONTEUDO:
|
||||
ref.pop(c, None), atual.pop(c, None)
|
||||
divergem = [c for c in sorted(set(ref) | set(atual)) if ref.get(c) != atual.get(c)]
|
||||
checar(set(novo["totais"]) <= {"ust_total", "ust_por_item", "prazo_calendario_semanas", "moeda", "valor_total"},
|
||||
f"{f.parent.name}: totais só com as chaves de sempre")
|
||||
checar(not divergem, f"{f.parent.name}: mesmas chaves, na mesma ordem, em "
|
||||
f"{len(ref)} caminhos" + (f" — divergem: {divergem}" if divergem else ""))
|
||||
a, b = dict(_achatar(referencia)), dict(_achatar(novo))
|
||||
mudados = sorted(k for k in set(a) | set(b) if a.get(k) != b.get(k))
|
||||
fora = [k for k in mudados if not VALORES_QUE_O_CONTRATO_MUDOU.search(k)]
|
||||
# Os JSONs do backlog são regravados pelo próprio uso da app; quando a
|
||||
# referência já é uma exportação nova, `mudados` fica vazio e isso é o
|
||||
# esperado. O que reprova é diferença FORA do que o contrato pediu.
|
||||
checar(not fora,
|
||||
f"{f.parent.name}: nenhum valor mudou fora de linha de OS e nome de perfil "
|
||||
f"({len(mudados)} campos diferentes)" + (f" — FORA: {fora[:5]}" if fora else ""))
|
||||
checar(pares >= 6, f"{pares} pares antigo × novo comparados (P7-SD31 e demais do backlog)")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Contrato
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def cumpre_contrato(nome: str, payload: dict, canon) -> None:
|
||||
sd = payload["sd"]
|
||||
ents = payload["entregaveis"]
|
||||
problemas = []
|
||||
if sd["status"] not in STATUS_SD:
|
||||
problemas.append(f"sd.status {sd['status']!r}")
|
||||
for chave in ("numero_sequencial", "nome", "objetivo", "inicio", "fim"):
|
||||
if sd.get(chave) in (None, ""):
|
||||
problemas.append(f"sd.{chave} vazio")
|
||||
if not sd["po_responsavel"]["nome"]:
|
||||
problemas.append("po vazio")
|
||||
if not re.match(r"^V\d+$", payload["_governanca"]["versao_documento"] or ""):
|
||||
problemas.append("versao_documento")
|
||||
if not ents:
|
||||
problemas.append("sem entregáveis")
|
||||
ns = [e["n"] for e in ents]
|
||||
if len(ns) != len(set(ns)):
|
||||
problemas.append("n repetido")
|
||||
for e in ents:
|
||||
a, b = date.fromisoformat(e["data_inicio"]), date.fromisoformat(e["data_prevista_termino"])
|
||||
dias = (b - a).days
|
||||
if dias < 0 or dias > JANELA_MAXIMA_DIAS:
|
||||
problemas.append(f"n{e['n']} janela {dias}d")
|
||||
sem = semanas_por_datas(a, b)
|
||||
if e["numero_semanas"] != sem:
|
||||
problemas.append(f"n{e['n']} semanas {e['numero_semanas']} ≠ {sem}")
|
||||
# Caminho A: UST, tarifa e valor são do importador. Saem null, sempre —
|
||||
# um número aqui seria o exportador voltando a copiar tabela alheia.
|
||||
for chave in ("ust", "valor_unitario_ust", "valor_entregavel", "horas_semanais", "memoria_calculo"):
|
||||
if chave in e and e[chave] is not None:
|
||||
problemas.append(f"n{e['n']} {chave} deveria ser null")
|
||||
if not re.match(r"^\d+-L\d+$", e["ordem_servico"]["linha"] or ""):
|
||||
problemas.append(f"n{e['n']} linha {e['ordem_servico']['linha']!r}")
|
||||
if e["status"] not in STATUS_ENTREGAVEL:
|
||||
problemas.append(f"n{e['n']} status {e['status']!r}")
|
||||
if e["item"] not in sd["itens_contratuais"]:
|
||||
problemas.append(f"n{e['n']} item fora de itens_contratuais")
|
||||
for al in e["alocacoes"]:
|
||||
if al["perfil"] not in PERFIS_DO_CADASTRO:
|
||||
problemas.append(f"n{e['n']} perfil {al['perfil']!r}")
|
||||
for lista in ("backlog", "criterios_aceite"):
|
||||
ordens = [x["ordem"] for x in e[lista]]
|
||||
if ordens != list(range(1, len(ordens) + 1)) or any(not x["descricao"] for x in e[lista]):
|
||||
problemas.append(f"n{e['n']} {lista}")
|
||||
nomes = [d["nome"] for d in e["documentacao"]]
|
||||
if len(nomes) != len(set(nomes)) or any(not x for x in nomes):
|
||||
problemas.append(f"n{e['n']} documentacao")
|
||||
checar(not problemas, f"{nome}: cumpre o contrato" + (f" — {problemas}" if problemas else ""))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3 + 4. Pendências esperadas, mesma régua nos dois caminhos
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def acervo(canon) -> None:
|
||||
print("\n· PENDÊNCIAS: cada SD do acervo reprova (ou não) pelos motivos esperados")
|
||||
print("· MESMA RÉGUA: app (ruamel) × CLI (safe_load), pendências e bytes iguais")
|
||||
vistos = set()
|
||||
for f in sds_reais():
|
||||
nome = f.parent.name
|
||||
vistos.add(nome)
|
||||
sd_cli, corpo_cli = ler_sd_arquivo(f)
|
||||
sd_app, corpo_app = ler_sd_arquivo(f, preservar_comentarios=True)
|
||||
p_cli, pend_cli = analisar(sd_cli, corpo_cli, canon)
|
||||
p_app, pend_app = analisar(sd_app, corpo_app, canon)
|
||||
assinatura = [(p.escopo, p.indice, p.campo) for p in pend_app]
|
||||
|
||||
esperado = ESPERADO.get(nome)
|
||||
if esperado is None:
|
||||
checar(False, f"{nome}: SD nova no acervo — declare o esperado em ESPERADO")
|
||||
else:
|
||||
checar(sorted(assinatura, key=str) == sorted(esperado, key=str),
|
||||
f"{nome}: pendências {assinatura or 'nenhuma'}")
|
||||
checar([str(p) for p in pend_cli] == [str(p) for p in pend_app],
|
||||
f"{nome}: mesmas pendências nos dois caminhos")
|
||||
if pend_app:
|
||||
try:
|
||||
montar(sd_app, corpo_app, canon)
|
||||
checar(False, f"{nome}: montar() gerou payload com pendência aberta")
|
||||
except ExportacaoReprovada as exc:
|
||||
checar(len(exc.pendencias) == len(pend_app), f"{nome}: montar() levanta as mesmas pendências")
|
||||
|
||||
# Como a SD fica depois de a pessoa preencher `linha_os`: se só faltava
|
||||
# isso, o payload completo tem de cumprir o contrato e sair igual nos
|
||||
# dois caminhos.
|
||||
com_linhas(sd_cli), com_linhas(sd_app)
|
||||
if analisar(sd_app, corpo_app, canon)[1]:
|
||||
continue # pendente por outro motivo — coberto acima
|
||||
s_cli = serializar(montar(sd_cli, corpo_cli, canon))
|
||||
s_app = serializar(montar(sd_app, corpo_app, canon))
|
||||
checar(s_cli == s_app, f"{nome}: mesmos bytes (com linha_os preenchido)")
|
||||
cumpre_contrato(nome, json.loads(s_app), canon)
|
||||
for nome in ESPERADO:
|
||||
if nome not in vistos:
|
||||
checar(False, f"{nome}: consta em ESPERADO mas não existe mais no acervo")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Regras do contrato que o acervo não exercita
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CORPO_OK = "# T\n\n## 1. Objetivo\n\nFazer algo útil.\n\n## 5. Enquadramento\n\n"
|
||||
|
||||
|
||||
def _sd(entregaveis: str, topo: str = "") -> tuple[dict, str]:
|
||||
fm = ("---\n"
|
||||
'sd: "P9·SD99"\n'
|
||||
'projeto: "P9 · Teste"\n'
|
||||
'titulo: "SD sintética"\n'
|
||||
'po_responsavel: "Alguém do Cadastro"\n'
|
||||
'versao: "V1"\n'
|
||||
"estado: rascunho\n"
|
||||
+ topo
|
||||
+ "entregaveis:\n" + entregaveis + "---\n")
|
||||
return ler_sd_texto(fm + _CORPO_OK, preservar_comentarios=True)
|
||||
|
||||
|
||||
def _pend(canon, entregaveis: str, topo: str = "") -> list[tuple]:
|
||||
sd, corpo = _sd(entregaveis, topo)
|
||||
return [(p.escopo, p.indice, p.campo) for p in analisar(sd, corpo, canon)[1]]
|
||||
|
||||
|
||||
ENT_SEM_LINHA = (' - n: 1\n nome: "Um"\n tipo: Descoberta\n item: I-02\n'
|
||||
" inicio: 2026-09-01\n fim: 2026-09-11\n")
|
||||
ENT_OK = ENT_SEM_LINHA + ' linha_os: "1090-L1"\n'
|
||||
|
||||
|
||||
def regras_sinteticas(canon) -> None:
|
||||
print("\n· CONTRATO: regras que o acervo de hoje não tripa, em frontmatter sintético")
|
||||
checar(_pend(canon, ENT_OK) == [], "o caso-base sintético exporta sem pendência")
|
||||
|
||||
def caso(desc, entregaveis, esperado, topo=""):
|
||||
got = _pend(canon, entregaveis, topo)
|
||||
checar(sorted(got, key=str) == sorted(esperado, key=str), f"{desc}: {got}")
|
||||
|
||||
caso("linha_os ausente", ENT_SEM_LINHA, [("entregavel", 0, "linha_os")])
|
||||
caso("linha_os nula", ENT_SEM_LINHA + " linha_os: null\n", [("entregavel", 0, "linha_os")])
|
||||
caso("linha_os fora do formato", ENT_SEM_LINHA + ' linha_os: "L1"\n', [("entregavel", 0, "linha_os")])
|
||||
# Só formato: existência, item e status são do banco, na carga. Uma linha
|
||||
# que o repositório não conhece PASSA aqui, de propósito.
|
||||
checar(_pend(canon, ENT_SEM_LINHA + ' linha_os: "9999-L7"\n') == [],
|
||||
"linha_os bem formada que o repositório não conhece passa — quem confere é o banco")
|
||||
caso("I-01 (Licença) não entra pela carga", ENT_OK.replace("I-02", "I-01"), [("entregavel", 0, "item")])
|
||||
caso("item desconhecido", ENT_OK.replace("I-02", "I-07"), [("entregavel", 0, "item")])
|
||||
caso("tipo Manutenção para a carga", ENT_OK.replace("Descoberta", "Manutenção"), [("entregavel", 0, "tipo")])
|
||||
caso("tipo fora do dicionário", ENT_OK.replace("Descoberta", "Discovery"), [("entregavel", 0, "tipo")])
|
||||
caso("`n` repetido", ENT_OK + ENT_OK.replace('nome: "Um"', 'nome: "Dois"'), [("entregavel", 1, "n")])
|
||||
caso("`n` zero", ENT_OK.replace("n: 1", "n: 0"), [("entregavel", 0, "n")])
|
||||
caso("sem nome", ENT_OK.replace('nome: "Um"', 'nome: ""'), [("entregavel", 0, "nome")])
|
||||
caso("UST declarada no .md (é do importador)", ENT_OK + " ust: 80\n", [("entregavel", 0, "ust")])
|
||||
caso("fim antes do início", ENT_OK.replace("fim: 2026-09-11", "fim: 2026-08-30"), [("entregavel", 0, "fim")])
|
||||
caso("dia inexistente", ENT_OK.replace("fim: 2026-09-11", 'fim: "2026-02-30"'), [("entregavel", 0, "fim")])
|
||||
caso("janela de 29 dias", ENT_OK.replace("fim: 2026-09-11", "fim: 2026-09-30"), [("entregavel", 0, "fim")])
|
||||
checar(_pend(canon, ENT_OK.replace("fim: 2026-09-11", "fim: 2026-09-29")) == [],
|
||||
"janela de exatamente 28 dias passa (4 semanas)")
|
||||
caso("documentação com nome repetido",
|
||||
ENT_OK + ' artefatos:\n - nome: "Doc"\n - nome: "Doc"\n', [("entregavel", 0, "artefatos")])
|
||||
caso("documentação sem nome", ENT_OK + ' artefatos:\n - nome: ""\n', [("entregavel", 0, "artefatos")])
|
||||
caso("item vazio no backlog", ENT_OK + ' atividades:\n - "a"\n - ""\n', [("entregavel", 0, "atividades")])
|
||||
caso("alocacoes_desvio com perfil fora do cadastro",
|
||||
ENT_OK + ' alocacoes_desvio:\n - {perfil: "Mago", quantidade: 1, percentual: 50}\n',
|
||||
[("entregavel", 0, "alocacoes_desvio")])
|
||||
caso("alocacoes_desvio com percentual > 100",
|
||||
ENT_OK + ' alocacoes_desvio:\n - {perfil: "Scrum Master", quantidade: 1, percentual: 150}\n',
|
||||
[("entregavel", 0, "alocacoes_desvio")])
|
||||
sd, corpo = _sd(ENT_OK)
|
||||
sd["estado"] = "voando"
|
||||
checar([(p.escopo, p.campo, bool(p.opcoes)) for p in analisar(sd, corpo, canon)[1]]
|
||||
== [("sd", "estado", True)], "estado desconhecido: pendência com a lista de opções")
|
||||
sd, corpo = _sd(ENT_OK)
|
||||
sd["versao"] = "1.0"
|
||||
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "versao")],
|
||||
"versão fora de V{n}")
|
||||
sd, corpo = _sd(ENT_OK)
|
||||
sd["sd"] = "SD99"
|
||||
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "sd")],
|
||||
"identificador fora de P{n}·SD{n}")
|
||||
sd, corpo = _sd(ENT_OK)
|
||||
sd["dependencias"] = ["solta"]
|
||||
checar([(p.escopo, p.campo) for p in analisar(sd, corpo, canon)[1]] == [("sd", "dependencias")],
|
||||
"dependencias em lista solta")
|
||||
corpo_ruim = _CORPO_OK + "| **Item do TR** | I-09 — não existe |\n"
|
||||
sd, _ = _sd(ENT_OK)
|
||||
checar([(p.escopo, p.campo) for p in analisar(sd, corpo_ruim, canon)[1]] == [("corpo", "secao5")],
|
||||
"Seção 5 com item fora do canônico")
|
||||
|
||||
# O caminho positivo do que é raro no acervo: linha declarada (1091-L1, a
|
||||
# segunda OS de I-02) e alocacoes_desvio normalizada.
|
||||
sd, corpo = _sd(ENT_SEM_LINHA + ' linha_os: "1091-L1"\n'
|
||||
' alocacoes_desvio:\n'
|
||||
' - {perfil: "Especialista de infraestrutura", quantidade: 2, percentual: 50}\n')
|
||||
e = montar(sd, corpo, canon)["entregaveis"][0]
|
||||
checar(e["ordem_servico"]["linha"] == "1091-L1", "linha_os declarada vai como está para o JSON")
|
||||
checar(e["alocacoes"] == [{"perfil": "Especialista de Infraestrutura", "quantidade": 2,
|
||||
"percentual_alocacao": 50}],
|
||||
"alocacoes_desvio sai com o nome do cadastro e a chave percentual_alocacao")
|
||||
checar(e["numero_semanas"] == 2 and e["ust"] is None and e["valor_unitario_ust"] is None
|
||||
and e["valor_entregavel"] is None and e["horas_semanais"] is None,
|
||||
"11 dias → 2 semanas; UST, tarifa e valor saem null — o importador deriva (§11 do contrato)")
|
||||
checar(list(e) == ["n", "codigo", "titulo", "tipo_entrega", "item", "ordem_servico", "data_inicio",
|
||||
"data_prevista_termino", "numero_semanas", "status", "horas_semanais", "ust",
|
||||
"valor_unitario_ust", "valor_entregavel", "memoria_calculo", "alocacoes",
|
||||
"backlog", "criterios_aceite", "documentacao"],
|
||||
"as chaves do entregável ficam, na mesma ordem, mesmo com os valores em null")
|
||||
for estado, sd_status, ent_status in (("emitida", "Em andamento", "Emitido"),
|
||||
("validado", "Entregue", "Aprovado"),
|
||||
("bloqueada", "Planejado", "Rascunho")):
|
||||
sd, corpo = _sd(ENT_OK)
|
||||
sd["estado"] = estado
|
||||
p = montar(sd, corpo, canon)
|
||||
checar(p["sd"]["status"] == sd_status and p["entregaveis"][0]["status"] == ent_status,
|
||||
f"estado {estado} → sd.status {sd_status!r}, entregável {ent_status!r}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema — por estrutura
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def schema_valida(canon) -> None:
|
||||
"""Todo JSON que sai precisa validar contra o sd-schema.json.
|
||||
|
||||
O schema deixou de ser o portão do download (o contrato da carga é), mas
|
||||
continua descrevendo a ESTRUTURA — additionalProperties: false na raiz e em
|
||||
_governanca. Se um campo novo aparecer, ele acusa aqui.
|
||||
"""
|
||||
print("\n· JSON exportado valida contra sd-schema.json (estrutura)")
|
||||
try:
|
||||
import jsonschema
|
||||
except ImportError:
|
||||
checar(False, "jsonschema não instalado — pip install jsonschema")
|
||||
return
|
||||
esquema = json.loads((PROJETOS / "_template" / "sd-schema.json").read_text(encoding="utf-8"))
|
||||
for f in sds_reais():
|
||||
nome = f.parent.name
|
||||
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
|
||||
if analisar(com_linhas(sd), corpo, canon)[1]:
|
||||
continue # não exporta nem com linha_os preenchido — não há JSON a validar
|
||||
queixa = None
|
||||
try:
|
||||
jsonschema.validate(json.loads(serializar(montar(sd, corpo, canon))), esquema)
|
||||
except jsonschema.ValidationError as exc:
|
||||
caminho = "/".join(str(p) for p in exc.absolute_path) or "(raiz)"
|
||||
queixa = f"{caminho} — {exc.message[:120]}"
|
||||
conhecida = SCHEMA_CONHECIDO.get(nome)
|
||||
if queixa and conhecida:
|
||||
print(f" aviso {nome}: {queixa}")
|
||||
print(f" └ conhecido: {conhecida}")
|
||||
elif queixa:
|
||||
checar(False, f"{nome}: {queixa}")
|
||||
elif conhecida:
|
||||
checar(False, f"{nome}: a divergência conhecida foi resolvida — tire de SCHEMA_CONHECIDO")
|
||||
else:
|
||||
checar(True, f"{nome}: valida")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5. Peças de regras_sd
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def tipos_do_ruamel() -> None:
|
||||
print("\n· Tipos que o ruamel devolve (as regras dependem deles)")
|
||||
amostra = ("---\n"
|
||||
"redigida: false\n"
|
||||
"formalizacao: {sd_emitida: false, os_mae: {I-02: true}}\n"
|
||||
"semanas: 3\n"
|
||||
"data_abertura: 2026-08-11\n"
|
||||
"---\ncorpo\n")
|
||||
d, corpo = ler_sd_texto(amostra, preservar_comentarios=True)
|
||||
checar(d["redigida"] is False, "`redigida: false` continua sendo o singleton False")
|
||||
checar(d["formalizacao"]["os_mae"]["I-02"] is True, "`os_mae: true` é o singleton True")
|
||||
checar(isinstance(d["semanas"], int), "`semanas` passa no isinstance(int)")
|
||||
checar(isinstance(d["data_abertura"], date), "data crua vira datetime.date")
|
||||
checar(isinstance(d, dict), "o mapa é subclasse de dict")
|
||||
checar(corpo.strip() == "corpo", "o corpo sai separado do frontmatter")
|
||||
checar("_corpo" not in d, "o frontmatter NÃO carrega `_corpo`")
|
||||
|
||||
|
||||
def secoes() -> None:
|
||||
print("\n· secao() e definir_secao() são a régua única para as seções do corpo")
|
||||
corpo_torto = "# t\n\n## 5.Enquadramento no TR\n\ntexto\n"
|
||||
checar(secao(corpo_torto, "5") is None, "`## 5.Enquadramento` (sem espaço) não é Seção 5")
|
||||
corpo_ok = "# t\n\n## 5. Enquadramento no TR\n\ntexto\n"
|
||||
checar(secao(corpo_ok, "5") == "texto", "`## 5. Enquadramento` é reconhecido")
|
||||
|
||||
# A P2·SD10 não tem Seção 1: o objetivo entra antes da 5, e as demais ficam.
|
||||
sd10 = "# Título\n\n## 5. Enquadramento\n\ntabela\n\n## 6. Fora de escopo\n\nfora\n"
|
||||
novo = definir_secao(sd10, "1", "Objetivo escrito na tela.")
|
||||
checar(secao(novo, "1") == "Objetivo escrito na tela.", "Seção 1 criada quando não existia")
|
||||
checar(novo.index("## 1.") < novo.index("## 5."), "criada ANTES da Seção 5, na ordem do template")
|
||||
checar(secao(novo, "5") == "tabela" and secao(novo, "6") == "fora", "as outras seções ficam intactas")
|
||||
# Existente: só o conteúdo muda; o cabeçalho e o resto ficam.
|
||||
com1 = "# T\n\n## 1. Objetivo da SD\n\nvelho\ntexto\n\n## 2. Contexto\n\nctx\n"
|
||||
novo = definir_secao(com1, "1", "novo")
|
||||
checar(secao(novo, "1") == "novo" and "## 1. Objetivo da SD" in novo and secao(novo, "2") == "ctx",
|
||||
"Seção 1 existente: conteúdo trocado, cabeçalho e Seção 2 preservados")
|
||||
|
||||
|
||||
def enquadramento_da_secao5(canon) -> None:
|
||||
print("\n· enquadramento_tr() lê a tabela da Seção 5 nas formas do acervo")
|
||||
itens = canon.itens["itens"]
|
||||
|
||||
def s5(corpo):
|
||||
return enquadramento_tr(secao(corpo, "5"), itens)
|
||||
|
||||
cabeca = "## 5. Enquadramento no Termo de Referência\n\n| | |\n|---|---|\n"
|
||||
corpo = (cabeca
|
||||
+ "| **Item do TR** | I-02 — Desenvolvimento e implantação |\n"
|
||||
+ "| **Descrição do item** | Conforme TR 1.6.4.1 |\n"
|
||||
+ "| **Aderência desta SD** | • um<br>• dois<br>• três |\n")
|
||||
b = s5(corpo)
|
||||
checar(len(b) == 1 and b[0]["item"] == "I-02", "célula com <br>: uma entrada, item I-02")
|
||||
checar(b[0]["aderencia"] == ["um", "dois", "três"], "os '•' viram lista, sem marcador residual")
|
||||
corpo = (cabeca
|
||||
+ "| **Item do TR** | I-05 — \"Serviços Técnicos\" |\n"
|
||||
+ "| **Aderência desta SD** | ver bullets abaixo |\n\n"
|
||||
+ "- primeiro bullet que quebra\n em duas linhas\n"
|
||||
+ "- **Avaliação Técnica** — com negrito\n- terceiro\n\nFecho.\n")
|
||||
b = s5(corpo)
|
||||
checar(b[0]["aderencia"] == ["primeiro bullet que quebra em duas linhas",
|
||||
"**Avaliação Técnica** — com negrito", "terceiro"],
|
||||
"indentação pendurada recolada, negrito intacto, fecho fora")
|
||||
corpo = cabeca + "| **Item do TR** | I-09 — item que não existe |\n"
|
||||
try:
|
||||
s5(corpo)
|
||||
checar(False, "item fora do canônico levanta ValueError")
|
||||
except ValueError as exc:
|
||||
checar("I-09" in str(exc), "o erro nomeia o item inválido")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
for stream in (sys.stdout, sys.stderr):
|
||||
if hasattr(stream, "reconfigure"):
|
||||
stream.reconfigure(encoding="utf-8", errors="replace")
|
||||
|
||||
canon = carregar_canonico()
|
||||
estrutura_intacta(canon)
|
||||
acervo(canon)
|
||||
regras_sinteticas(canon)
|
||||
schema_valida(canon)
|
||||
tipos_do_ruamel()
|
||||
secoes()
|
||||
enquadramento_da_secao5(canon)
|
||||
|
||||
print()
|
||||
if FALHAS:
|
||||
print(f"{len(FALHAS)} falha(s):")
|
||||
for f in FALHAS:
|
||||
print(f" · {f}")
|
||||
return 1
|
||||
print("tudo bate.")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user