#!/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[^·\s]+)\s*·\s*SD(?P\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())