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:
2026-09-04 14:07:38 -03:00
co-authored by Claude Sonnet 5
commit 0d4fdd2533
29 changed files with 4268 additions and 0 deletions
+53
View File
@@ -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"))
+711
View File
@@ -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())
+519
View File
@@ -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
+402
View File
@@ -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())
+529
View File
@@ -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())