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>
737 lines
33 KiB
Python
737 lines
33 KiB
Python
#!/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())
|