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>
This commit is contained in:
2026-09-04 20:22:15 -03:00
co-authored by Claude Fable 5.1
parent 4ef1a7a4c8
commit a4b1374fad
11 changed files with 329 additions and 121 deletions
+35 -41
View File
@@ -43,6 +43,9 @@ import servidor # noqa: E402
FALHAS: list[str] = []
CLIENTE = servidor.app.test_client()
# Lida da tabela, não fixada: o cabeçalho de linhas-os.yaml manda atualizar
# `atualizado_em` a cada edição, e um teste pinado ficaria vermelho à toa.
DATA_TABELA = servidor.canonico().linhas_os["atualizado_em"]
SD8 = PROJETOS / "P2-vacinacao/sds/SD8-mvp-vacina-em-dia/SD.md"
SD10 = PROJETOS / "P2-vacinacao/sds/SD10-crie/SD.md"
@@ -119,56 +122,44 @@ def exportar_pela_cli(sd_md: Path) -> str:
# ---------------------------------------------------------------------------
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")
print("\n1. SD31: o .md não declara linha de OS; a tabela preenche, 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(estado_na_tela(pag) == "Pronta para exportar",
"estado: pronta — as 4 linhas que o SD.md não declara vieram de contrato/linhas-os.yaml")
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(pag.count(">tabela</span>") >= 4 and "linhas-os.yaml" in pag and DATA_TABELA in pag,
"a tela marca a origem em cada linha e diz a data da tabela")
ck("1090-L1" in pag, "a linha padrão de I-02 aparece no resumo")
ck("Exportar JSON" in pag and "disabled" not in pag.split("Exportar JSON")[0][-200:],
"o botão Exportar está habilitado")
ck(not baixou(CLIENTE.get("/revisar")), "GET /revisar não baixa nada — só o /exportar baixa")
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(all(e["item"] == "I-02" and e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]),
"todo entregável (I-02) saiu com a linha padrão do item na tabela")
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.
# A mesma régua da CLI, dos dois lados: sobre o SD.md ORIGINAL (a CLI
# consulta a mesma tabela) e sobre o SD.md gravado pelo Salvar SD.md (que
# agora declara as linhas, com a origem em comentário).
ck(baixado == exportar_pela_cli(SD31), "bytes idênticos aos do exporta_sd.py sobre o SD.md original")
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")
txt = alvo.read_text(encoding="utf-8") if alvo.exists() else ""
ck(txt.count("linha_os: 1090-L1") == 4 and txt.count(f"# da tabela linhas-os.yaml ({DATA_TABELA})") == 4,
"o SD.md gravado declara as quatro linhas, cada uma com a origem em comentário")
ck(baixado == exportar_pela_cli(alvo), "bytes idênticos aos do exporta_sd.py sobre o SD.md gravado")
@@ -237,13 +228,11 @@ def po_e_objetivo():
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(set(c) == {"sd__po_responsavel", "corpo__objetivo"},
f"PO e objetivo no formulário — nada mais: as cinco linhas de OS vieram da tabela ({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")
@@ -253,7 +242,7 @@ def po_e_objetivo():
"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")
"cada entregável saiu com o padrão do SEU item na tabela (n3 é I-02; os demais, I-05)")
def sem_datas():
@@ -330,7 +319,7 @@ def writeback():
def linha_de_os_no_formulario():
print("\n9. Linha de OS: só o formato é conferido aqui; existência é do banco, na carga")
print("\n9. Linha de OS: declarada, só o formato é conferido; apagada, a tabela preenche o padrão do item")
sd_md = ("---\n"
'sd: "P9·SD99"\nprojeto: "P9 · Teste"\ntitulo: "Sintética"\n'
'po_responsavel: "Alguém"\nversao: "V1"\nestado: rascunho\n'
@@ -341,28 +330,33 @@ def linha_de_os_no_formulario():
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")
ck('<input name="e0__linha_os"' in pag and "<select" not in pag,
"campo de texto: a pessoa corrige o que declarou, a tabela não se mete no declarado")
ck(">tabela</span>" not in pag, "linha declarada (mesmo errada) não é trocada pela da tabela")
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")
"linha bem formada que a tabela 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")
# Apagar a declaração é "não sei": a tabela entra e dá o padrão do item.
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")
ck(estado_na_tela(pag) == "Pronta para exportar" and ">tabela</span>" in pag,
"em branco, a tabela preenche o padrão de I-02 e a tela marca a origem")
e0 = servidor.ATUAL["sessao"].dados["entregaveis"][0]
ck(e0.get("linha_os") == "1090-L1", "a linha da tabela foi escrita no frontmatter da sessão (para o Salvar SD.md)")
p = json.loads(texto(post("/exportar")))
ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "1090-L1", "e é ela que vai no JSON")
def editar_depois_de_exportar():
print("\n10. Editar depois de exportar desfaz o rótulo 'exportada'")
reiniciar()
preencher_linhas(texto(enviar(SD31)))
enviar(SD31) # pronta de saída: as linhas de OS vêm da tabela
post("/exportar")
ck(estado_na_tela(pagina()) == "JSON exportado", "exportada")
post("/revisar", {"sd__titulo": "outro título"})