Files
wanderandClaude Fable 5.1 a4b1374fad Linha de OS: tabela linhas-os.yaml como fallback quando o SD.md não declara
Decisão de 2026-09-04, revendo a de 2026-09-03 (sem de-para): o .md continua
mandando, e a tabela contrato/linhas-os.yaml (OS Mãe × linha × item) só entra
quando o entregável não declara `linha_os`. Linha declarada vai como está e não
é conferida contra a tabela; existência/item/status seguem sendo do banco.

- caminhos.LINHAS_OS; regras_sd: Canonico(itens, linhas_os), carregar_linhas_os
  (arquivo ausente = fallback desligado), preparar_linhas_os (valida: formato,
  linha repetida, dois `padrao`, `padrao` não booleano) e linha_da_tabela.
- exporta_sd: ausente → padrão do item; 2+ candidatas sem padrão → pendência
  nomeando-as; item fora da tabela → pendência. CLI informa quantas linhas
  vieram da tabela.
- app: sessao.adotar_linhas_da_tabela grava a linha no frontmatter da sessão
  com comentário de origem, para o Salvar SD.md deixar a SD autodeclarada;
  a tela marca `tabela` e mostra a data da tabela.
- testes: SD18, SD27, SD31–36 passam a exportar de saída; SD10 só PO+objetivo;
  casos sintéticos para tabela ausente, ambígua, sem o item e malformada.
- deploy: linhas-os.yaml vive em clientes/ (gitignored) — vai por scp ao volume.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-04 20:22:15 -03:00

737 lines
33 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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: `ordem_servico.linha`. OS e linha são cadastro do sistema
de gestão e mudam durante o ano. Quem emite a SD declara `linha_os` no
entregável, copiado da tela da OS; aqui só o formato `{OS}-L{n}` é conferido, e
existência, item e status quem confere é o banco, na carga — que recusa
nomeando o que falta. AUSENTE, a tabela contrato/linhas-os.yaml (se existir)
dá a linha padrão do item — fallback, decidido em 2026-09-04, para poupar a
digitação; sem padrão é pendência, com as candidatas nomeadas quando a tabela
tem mais de uma. A linha declarada nunca é conferida contra a tabela (decisão
de 2026-09-03): o .md manda.
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,
linha_da_tabela,
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: vai como está, só o formato é conferido — OS e
# linha são cadastro do sistema de gestão, e quem sabe se existem é o
# banco, na carga. Ausente, a tabela contrato/linhas-os.yaml (se houver)
# dá a linha padrão do item; sem padrão é pendência, e com mais de uma
# candidata a mensagem as nomeia para a pessoa escolher.
linha = None
declarada = e.get("linha_os")
if declarada in (None, ""):
padrao, candidatas = linha_da_tabela(canon.linhas_os, item) if item_ok else (None, [])
if padrao:
linha = padrao
elif len(candidatas) > 1:
falta("linha_os", f"`linha_os` ausente, e a tabela linhas-os.yaml tem {len(candidatas)} "
f"linhas para {item} sem `padrao` marcado: {', '.join(candidatas)}. "
"Escolha uma aqui, ou marque o padrão na tabela", opcoes=candidatas)
else:
sem_entrada = f" — a tabela linhas-os.yaml não tem linha para {item}" \
if canon.linhas_os and item_ok else ""
falta("linha_os", conferir_formato_linha_os(None) + sem_entrada)
else:
queixa = conferir_formato_linha_os(declarada)
if queixa:
falta("linha_os", queixa)
else:
linha = str(declarada).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
# Se montar() passou com `linha_os` ausente, a linha veio da tabela —
# dizer quantas: é a única pista de que o .md ainda não a declara.
da_tabela = sum(1 for e in (sd.get("entregaveis") or [])
if isinstance(e, dict) and e.get("linha_os") in (None, ""))
print(f" {rotulo} → {saida} {len(payload['entregaveis'])} entregável(is) "
f"({', '.join(payload['sd']['itens_contratuais'])})"
+ (f" · {da_tabela} linha(s) de OS da tabela linhas-os.yaml" if da_tabela else ""))
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())