From a4b1374fadebf8240807c393396c6c517897822a Mon Sep 17 00:00:00 2001 From: WanderMotta Date: Fri, 4 Sep 2026 20:22:15 -0300 Subject: [PATCH] =?UTF-8?q?Linha=20de=20OS:=20tabela=20linhas-os.yaml=20co?= =?UTF-8?q?mo=20fallback=20quando=20o=20SD.md=20n=C3=A3o=20declara?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .gitignore | 3 +- Dockerfile | 6 +- app/servidor.py | 6 ++ app/sessao.py | 43 ++++++++++- app/templates/revisar.html | 10 ++- docker-compose.yml | 3 +- scripts/caminhos.py | 3 + scripts/exporta_sd.py | 53 ++++++++++---- scripts/regras_sd.py | 106 ++++++++++++++++++++++++---- scripts/teste_e2e.py | 76 +++++++++----------- scripts/teste_fluxo.py | 141 ++++++++++++++++++++++++------------- 11 files changed, 329 insertions(+), 121 deletions(-) diff --git a/.gitignore b/.gitignore index fb2a8c0..b073b68 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,8 @@ scripts/backlog/ # Dados do cliente: contrato, projetos e SDs. Fora do git por decisão de # 04/09/2026. No deploy, entram por volume montado em /app/clientes — o -# app exige clientes/ses-mg/contrato/itens.yaml em tempo de execução. +# app exige clientes/ses-mg/contrato/itens.yaml em tempo de execução, e usa +# contrato/linhas-os.yaml (opcional) como fallback da linha de OS. clientes/ # Documentos de trabalho na RAIZ (contrato, propostas, prompts, leiame). diff --git a/Dockerfile b/Dockerfile index afa9c04..0f161f6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,10 @@ # Imagem da aplicação de Exportação da SD — deploy em VPS via painel Coolify. # # O que NÃO entra na imagem, e por que: clientes/. O app lê -# clientes/ses-mg/contrato/itens.yaml em tempo de execução (regras_sd.carregar_canonico) -# e grava SD.md de volta em clientes/ses-mg/projetos/ (escrita_sd.gravar). Esses +# clientes/ses-mg/contrato/itens.yaml em tempo de execução (regras_sd.carregar_canonico), +# consulta contrato/linhas-os.yaml se existir (fallback da linha de OS; ausente, +# `linha_os` é exigido no SD.md) e grava SD.md de volta em +# clientes/ses-mg/projetos/ (escrita_sd.gravar). Esses # são dados do cliente, versionados fora do git — entram por VOLUME montado em # /app/clientes. Sem esse volume, a imagem sobe e a tela inicial responde, mas o # primeiro upload falha com "Arquivo canônico ausente". diff --git a/app/servidor.py b/app/servidor.py index 1591516..0834dd2 100644 --- a/app/servidor.py +++ b/app/servidor.py @@ -362,12 +362,18 @@ def revisar(): sess = ATUAL["sessao"] if sess is None: return redirect(url_for("inicio")) + # A prévia lista os entregáveis pelo `n`, e a adoção da tabela é por índice + # no frontmatter: traduz para a tela marcar a origem na linha certa. + ents = sess.dados.get("entregaveis") or [] + da_tabela = {ents[i].get("n"): linha for i, linha in sess.linhas_da_tabela.items() + if i < len(ents) and isinstance(ents[i], dict)} return render_template( "revisar.html", s=sess, rotulo=sess.rotulo_estado(), formulario=montar_formulario(sess), previa=sess.previa or {}, + linhas_da_tabela=da_tabela, aviso=ATUAL.pop("aviso", None), ) diff --git a/app/sessao.py b/app/sessao.py index 2ea4012..d43dcb1 100644 --- a/app/sessao.py +++ b/app/sessao.py @@ -26,7 +26,38 @@ from pathlib import Path sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) from exporta_sd import Pendencia, analisar # noqa: E402 -from regras_sd import Canonico # noqa: E402 +from regras_sd import Canonico, linha_da_tabela # noqa: E402 + + +def adotar_linhas_da_tabela(dados, canon: Canonico) -> dict[int, str]: + """Escreve NO OBJETO DA SESSÃO a linha padrão da tabela onde o SD.md não + declara `linha_os`. Devolve {índice do entregável: linha} do que adotou. + + Por que mutar aqui, e não deixar só o exportador consultar a tabela: o + Salvar SD.md regrava este objeto. Com a linha adotada nele, a SD sai do + disco autodeclarada e a tabela deixa de importar para ela — o risco de + tabela envelhecida morde uma vez só. O comentário de linha diz de onde + veio, para quem abrir o .md depois. O exportador faz a mesma consulta + (mesmo helper, mesma tabela), então a CLI sobre o .md original e a app dão + os mesmos bytes. Idempotente: só toca em quem não declara. + """ + tabela = canon.linhas_os + if not tabela: + return {} + adotadas: dict[int, str] = {} + for i, e in enumerate(dados.get("entregaveis") or []): + if not isinstance(e, dict) or e.get("linha_os") not in (None, ""): + continue + padrao, _ = linha_da_tabela(tabela, e.get("item")) + if not padrao: + continue + e["linha_os"] = padrao + if hasattr(e, "yaml_add_eol_comment"): # CommentedMap do ruamel + data = tabela.get("atualizado_em") or "sem data" + e.yaml_add_eol_comment(f"da tabela linhas-os.yaml ({data}) — o SD.md não declarava", + "linha_os") + adotadas[i] = padrao + return adotadas # --------------------------------------------------------------------------- # Estados — os que a tela precisa distinguir, e nenhum a mais @@ -56,6 +87,10 @@ class Sessao: caminho_origem: Path | None = None ultimo_erro: str | None = None exportada_em: str | None = None + # {índice do entregável: linha} adotado de linhas-os.yaml porque o SD.md + # não declarava — a tela marca a origem, e o Salvar SD.md grava. + linhas_da_tabela: dict = field(default_factory=dict) + tabela_atualizada_em: str | None = None def reavaliar(self, canon: Canonico) -> None: """Roda o exportador em modo de análise sobre os dados COMO ESTÃO. @@ -63,7 +98,13 @@ class Sessao: É grátis e instantâneo; roda a cada edição. Uma edição depois da exportação volta a sessão para pronta/pendente: o que foi exportado era outra versão dos dados, e a tela não pode dizer "exportada" sobre esta. + Antes de analisar, adota da tabela a linha de OS que o .md não declara + (ver adotar_linhas_da_tabela) — e só então o exportador vê os dados. """ + adotadas = adotar_linhas_da_tabela(self.dados, canon) + if adotadas: + self.linhas_da_tabela.update(adotadas) + self.tabela_atualizada_em = (canon.linhas_os or {}).get("atualizado_em") self.previa, self.pendencias = analisar(self.dados, self.corpo, canon) self.estado = PRONTA if not self.pendencias else COM_PENDENCIAS self.exportada_em = None diff --git a/app/templates/revisar.html b/app/templates/revisar.html index 607615b..e2d2dc2 100644 --- a/app/templates/revisar.html +++ b/app/templates/revisar.html @@ -111,7 +111,7 @@ {{ e.titulo }} {{ e.tipo_entrega }} {{ e.item }} - {{ e.ordem_servico.linha or '—' }} + {{ e.ordem_servico.linha or '—' }}{% if e.n in linhas_da_tabela %} tabela{% endif %} {{ e.data_inicio or '—' }} {{ e.data_prevista_termino or '—' }} {% if e.data_inicio and e.data_prevista_termino %}{{ dias(e.data_inicio, e.data_prevista_termino) }}{% else %}—{% endif %} @@ -126,6 +126,14 @@ valor não saem daqui: o sistema os calcula na carga a partir de datas, item e tipo, com as tabelas vigentes. Linha de OS é o linha_os declarado no SD.md; quem confere se a OS e a linha existem é o sistema de gestão, na carga.

+ {% if linhas_da_tabela %} +

tabela {{ linhas_da_tabela|length }} entregável(is) não + declaravam linha_os no SD.md: a linha veio de contrato/linhas-os.yaml + (atualizada em {{ s.tabela_atualizada_em or 'data não informada' }}), pelo padrão do item. + Salvar SD.md… grava essa linha no arquivo, com a origem em comentário — + a SD passa a declará-la e a tabela deixa de importar para ela. Se a OS certa for outra, + corrija no SD.md.

+ {% endif %} {% endblock %} diff --git a/docker-compose.yml b/docker-compose.yml index 504809b..6e106ee 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -37,7 +37,8 @@ services: PORT: "5000" volumes: # OBRIGATÓRIO. O app lê clientes/ses-mg/contrato/itens.yaml a cada - # análise e grava SD.md em clientes/ses-mg/projetos/. O caminho no + # análise (e contrato/linhas-os.yaml, se existir, como fallback da linha + # de OS) e grava SD.md em clientes/ses-mg/projetos/. O caminho no # container é fixo: caminhos.py resolve clientes/ a partir da raiz do # código, que é o WORKDIR /app. Sem esta montagem, a tela inicial abre # e o primeiro upload falha com "Arquivo canônico ausente". diff --git a/scripts/caminhos.py b/scripts/caminhos.py index a2c61da..3e1669b 100644 --- a/scripts/caminhos.py +++ b/scripts/caminhos.py @@ -21,6 +21,9 @@ METODOS = RAIZ / "metodos" DERIVADOS = RAIZ / "_derivados" ITENS = CONTRATO / "itens.yaml" +# Fallback de linha de OS (OS Mãe × linha × item). OPCIONAL: ausente, o +# exportador exige `linha_os` declarado no SD.md, como antes de 2026-09-04. +LINHAS_OS = CONTRATO / "linhas-os.yaml" # Visões GERADAS a partir das SDs (inversão da V0, 13/08/2026). Quem lê # portfólio, carteira ou catálogo lê o resultado de `make derivar` — os diff --git a/scripts/exporta_sd.py b/scripts/exporta_sd.py index 20c4c1e..9dd3552 100644 --- a/scripts/exporta_sd.py +++ b/scripts/exporta_sd.py @@ -28,12 +28,15 @@ reajuste. As chaves continuam no JSON, na mesma posição — só o valor é `nu `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 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 @@ -85,6 +88,7 @@ from regras_sd import ( enquadramento_tr, janela_dias, ler_sd_arquivo, + linha_da_tabela, perfil_do_cadastro, secao, semanas_por_datas, @@ -421,15 +425,31 @@ def _montar(sd: dict, corpo: str, canon: Canonico, pend: list[Pendencia]) -> dic "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. + # 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 - queixa = conferir_formato_linha_os(e.get("linha_os")) - if queixa: - falta("linha_os", queixa) + 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: - linha = str(e.get("linha_os")).strip() + 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"): @@ -695,8 +715,13 @@ def main() -> int: 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"({', '.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 diff --git a/scripts/regras_sd.py b/scripts/regras_sd.py index dcdc4b1..79817b8 100644 --- a/scripts/regras_sd.py +++ b/scripts/regras_sd.py @@ -30,6 +30,7 @@ except ImportError: sys.exit("Falta pyyaml. pip install pyyaml") from caminhos import ITENS as ITENS_YAML +from caminhos import LINHAS_OS as LINHAS_OS_YAML # --------------------------------------------------------------------------- @@ -40,23 +41,44 @@ class Canonico(NamedTuple): """O YAML de contrato, carregado uma vez. `itens` dá a lista de itens do contrato e a matriz de perfis do TR - 4.1.1.7.1, que alimenta `alocacoes`. É o ÚNICO arquivo de dados de que o - exportador depende. Time-box e tarifa saíram daqui em 2026-09-03: são - tabelas do sistema de gestão, e é ele que deriva UST e valor na carga. + 4.1.1.7.1, que alimenta `alocacoes`. Time-box e tarifa saíram daqui em + 2026-09-03: são tabelas do sistema de gestão, e é ele que deriva UST e + valor na carga. - O que NÃO está aqui, de propósito: OS e linhas de OS. Elas são cadastro do - sistema de gestão e mudam durante o ano; qualquer cópia local envelhece em - silêncio. O exportador exige `linha_os` declarado no SD.md e só confere o - formato — existência, item e status quem confere é o banco, na carga. + `linhas_os` é a tabela OS Mãe × linha × item (contrato/linhas-os.yaml), já + preparada por preparar_linhas_os() — ou None quando o arquivo não existe. + É FALLBACK, não fonte: só entra quando o entregável NÃO declara `linha_os` + no SD.md. Linha declarada vai como está, e existência, item e status quem + confere é o banco, na carga. Decisão de 2026-09-04, revendo a de + 2026-09-03 (que não admitia de-para nenhum): o .md continua mandando; a + tabela poupa a digitação, e a aplicação grava no .md a linha que usou. """ itens: dict + linhas_os: dict | None = None def carregar_canonico() -> Canonico: if not ITENS_YAML.exists(): raise FileNotFoundError(f"Arquivo canônico ausente: {ITENS_YAML}") itens = yaml.safe_load(ITENS_YAML.read_text(encoding="utf-8")) - return Canonico(itens) + return Canonico(itens, carregar_linhas_os()) + + +def carregar_linhas_os(caminho: Path = LINHAS_OS_YAML) -> dict | None: + """A tabela de linhas de OS preparada, ou None quando o arquivo não existe. + + Ausente NÃO é erro: o deploy monta contrato/ por volume, e um volume sem o + arquivo tem de se comportar como antes de 2026-09-04 — `linha_os` exigido + no SD.md. Presente e malformado É erro, e alto: quem edita é gente, e uma + entrada torta silenciada apontaria linha errada sem ninguém notar. + """ + if not caminho.exists(): + return None + bruto = yaml.safe_load(caminho.read_text(encoding="utf-8")) + try: + return preparar_linhas_os(bruto) + except ValueError as exc: + raise ValueError(f"{caminho.name}: {exc}") from exc # --------------------------------------------------------------------------- @@ -500,10 +522,10 @@ def conferir_formato_linha_os(linha) -> str | None: Só FORMATO. Existência da OS, da linha, item e status são cadastro do sistema de gestão e é ele que os confere na carga (resolução em dois passos - no banco, §5) — a carga recusa nomeando o que falta e nada é gravado. Não - há cópia local disso aqui de propósito: OS e linhas novas entram durante o - ano, e um de-para em arquivo apontaria linha velha sem ninguém notar. - Decisão da gestão em 2026-09-03. + no banco, §5) — a carga recusa nomeando o que falta e nada é gravado. A + tabela linhas-os.yaml NÃO é conferida contra o valor declarado: ela é + fallback para a ausência (ver linha_da_tabela), não régua do que a pessoa + copiou da tela da OS. Decisões da gestão em 2026-09-03 e 2026-09-04. """ if linha in (None, ""): return ("`linha_os` ausente — ordem_servico.linha é obrigatório na carga e só quem " @@ -514,6 +536,66 @@ def conferir_formato_linha_os(linha) -> str | None: return None +# --------------------------------------------------------------------------- +# Tabela de linhas de OS — contrato/linhas-os.yaml (fallback) +# --------------------------------------------------------------------------- + +def preparar_linhas_os(bruto) -> dict: + """Valida o YAML da tabela e a indexa por item. + + Devolve {"atualizado_em", "linhas", "por_item": {item: [linha, ...]}, + "padrao": {item: linha}}. Reprova, nomeando a entrada: sem `linha` ou + `item`, `linha` fora de {OS}-L{n}, linha repetida, e mais de um + `padrao: true` no mesmo item — a marca existe para desfazer ambiguidade, + e duas a recriam. + """ + if not isinstance(bruto, dict) or not isinstance(bruto.get("linhas"), list): + raise ValueError("esperava um mapa com a lista `linhas`") + por_item: dict[str, list[str]] = {} + padrao: dict[str, str] = {} + vistas: set[str] = set() + for k, ent in enumerate(bruto["linhas"], 1): + if not isinstance(ent, dict): + raise ValueError(f"entrada {k} de `linhas` não é um mapa linha/item/padrao") + linha = " ".join(str(ent.get("linha") or "").split()) + item = " ".join(str(ent.get("item") or "").split()) + if not linha or not item: + raise ValueError(f"entrada {k} de `linhas` precisa de `linha` e `item`") + if not _LINHA_OS.match(linha): + raise ValueError(f"entrada {k}: linha {linha!r} fora do formato {{OS}}-L{{n}} (ex.: 1090-L1)") + if linha in vistas: + raise ValueError(f"linha {linha} aparece duas vezes") + vistas.add(linha) + por_item.setdefault(item, []).append(linha) + if "padrao" in ent and not isinstance(ent["padrao"], bool): + raise ValueError(f"entrada {k} ({linha}): `padrao` precisa ser true/false, " + f"não {ent['padrao']!r}") + if ent.get("padrao") is True: + if item in padrao: + raise ValueError(f"{item} tem duas linhas com `padrao: true` ({padrao[item]} e {linha}) " + "— só uma pode ser o padrão") + padrao[item] = linha + atualizado = bruto.get("atualizado_em") + return {"atualizado_em": str(atualizado) if atualizado is not None else None, + "linhas": bruto["linhas"], "por_item": por_item, "padrao": padrao} + + +def linha_da_tabela(tabela: dict | None, item) -> tuple[str | None, list[str]]: + """(linha padrão do item, todas as candidatas) — (None, []) sem tabela ou sem o item. + + Padrão é a linha marcada `padrao: true`; com UMA linha só para o item, ela + vale como padrão sem precisar da marca. Com duas ou mais e nenhuma marcada, + o padrão é None e o chamador pede para a pessoa escolher entre as candidatas. + """ + if not tabela or item is None: + return None, [] + candidatas = list(tabela["por_item"].get(str(item), [])) + padrao = tabela["padrao"].get(str(item)) + if padrao is None and len(candidatas) == 1: + padrao = candidatas[0] + return padrao, candidatas + + def janela_dias(inicio: date, fim: date) -> int: """Dias entre as datas, exclusivo — a mesma subtração que a carga faz.""" return (fim - inicio).days diff --git a/scripts/teste_e2e.py b/scripts/teste_e2e.py index 9e09484..8048843 100644 --- a/scripts/teste_e2e.py +++ b/scripts/teste_e2e.py @@ -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("tabela") >= 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('tabela" 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" 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"}) diff --git a/scripts/teste_fluxo.py b/scripts/teste_fluxo.py index 6a2d6f6..fad5350 100644 --- a/scripts/teste_fluxo.py +++ b/scripts/teste_fluxo.py @@ -9,8 +9,9 @@ O que está fixado aqui, na ordem em que custa mais errar: que o importador já consome (scripts/backlog/sd/P7-SD31.json). A rotina de importação depende disso; só VALORES podem mudar. 2. CONTRATO — cada SD que exporta cumpre CONTRATO-JSON-V2.md: linha de OS - preenchida, UST = time-box × semanas(datas), valor = UST × tarifa, janela - ≤ 28 dias, vocabulário exato da §6, unicidades da §8. + preenchida (declarada no .md ou, ausente, o padrão do item em + contrato/linhas-os.yaml), UST/tarifa/valor null para o importador derivar, + janela ≤ 28 dias, vocabulário exato da §6, unicidades da §8. 3. PENDÊNCIAS — as SDs que NÃO exportam ficam pendentes pelos motivos certos, campo a campo, e nenhuma outra. 4. MESMA RÉGUA — o caminho da app (ruamel) e o da CLI (safe_load) produzem @@ -33,8 +34,9 @@ from datetime import date from caminhos import PROJETOS, RAIZ, sds_reais from exporta_sd import ExportacaoReprovada, analisar, montar, serializar from regras_sd import (JANELA_MAXIMA_DIAS, PERFIS_DO_CADASTRO, STATUS_ENTREGAVEL, STATUS_SD, - carregar_canonico, definir_secao, enquadramento_tr, ler_sd_arquivo, - ler_sd_texto, secao, semanas_por_datas) + Canonico, carregar_canonico, definir_secao, enquadramento_tr, + ler_sd_arquivo, ler_sd_texto, linha_da_tabela, preparar_linhas_os, + secao, semanas_por_datas) FALHAS: list[str] = [] @@ -42,42 +44,28 @@ FALHAS: list[str] = [] # pendências esperadas como (escopo, indice, campo) — lista vazia = exporta. # Não é silenciamento: a suíte segue vermelha para qualquer pendência NOVA ou # para uma esperada que sumir sem o SD.md ter mudado. -def _sem_linha(n: int) -> list[tuple]: - """SD cujos n entregáveis ainda não declaram `linha_os` no SD.md.""" - return [("entregavel", i, "linha_os") for i in range(n)] - - +# +# Desde 2026-09-04 a falta de `linha_os` no .md deixou de ser pendência para os +# itens que contrato/linhas-os.yaml conhece: SD18, SD27 e SD31–SD36 (que só +# tinham isso) passaram a exportar, e SD10 ficou só com PO e objetivo. O que a +# tabela preencheu é conferido em acervo(), linha a linha. ESPERADO = { "SD8-mvp-vacina-em-dia": [("entregavel", 1, "semanas"), ("entregavel", 2, "semanas"), ("entregavel", 7, "fim")], - "SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")] + _sem_linha(5), - "SD18-integracao-core-rnds-samu": _sem_linha(4), + "SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")], + "SD18-integracao-core-rnds-samu": [], "SD20-siscan-d1": [("sd", None, "po_responsavel")] + [("entregavel", i, c) for i in range(5) for c in ("inicio", "fim")], "SD24-mvp-institucional": [("entregavel", 3, "semanas")], - "SD27-design-thinking-oficina-superintendencias": _sem_linha(3), - "SD31-fundacao-da-base-de-dispensacao": _sem_linha(4), - "SD32-extracao-automatizada-d-1-do-sigaf": _sem_linha(4), - "SD33-painel-institucional-de-dispensacao": _sem_linha(3), - "SD34-pacientes-ativos-e-alerta-de-inativacao": _sem_linha(3), - "SD35-programacao-estoque-e-faltas": _sem_linha(2), - "SD36-analise-de-processos-ceaf-com-ia-piloto": _sem_linha(3), + "SD27-design-thinking-oficina-superintendencias": [], + "SD31-fundacao-da-base-de-dispensacao": [], + "SD32-extracao-automatizada-d-1-do-sigaf": [], + "SD33-painel-institucional-de-dispensacao": [], + "SD34-pacientes-ativos-e-alerta-de-inativacao": [], + "SD35-programacao-estoque-e-faltas": [], + "SD36-analise-de-processos-ceaf-com-ia-piloto": [], } -# O que a pessoa escreveria hoje em `linha_os`, copiado da tabela §5 do contrato -# (banco de 2026-09-03). NÃO é de-para do exportador — ele não tem nenhum, por -# decisão da gestão. Serve só para os testes de contrato e de estrutura rodarem -# sobre um payload completo, como se a SD já estivesse preenchida. -LINHA_DE_TESTE = {"I-02": "1090-L1", "I-03": "2587-L1", "I-04": "2587-L2", "I-05": "2587-L3"} - - -def com_linhas(sd: dict) -> dict: - """A SD como ficaria depois de a pessoa preencher `linha_os` no formulário.""" - for e in sd.get("entregaveis") or []: - if isinstance(e, dict) and not e.get("linha_os"): - e["linha_os"] = LINHA_DE_TESTE.get(e.get("item"), "1090-L1") - return sd - # Regras do sd-schema.json que NÃO são do contrato da carga — a decisão da # gestão (2026-09-03) é deixar o importador rejeitar texto fora do teto, e a # régua de 3..5 bullets de aderência é redação do repositório, não da carga. @@ -143,8 +131,8 @@ def estrutura_intacta(canon) -> None: for f in sds_reais(): sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True) ref_path = RAIZ / "scripts/backlog/sd" / (str(sd.get("sd", "")).replace("·", "-") + ".json") - if not ref_path.exists() or analisar(com_linhas(sd), corpo, canon)[1]: - continue # sem JSON antigo para comparar, ou pendente por outro motivo + if not ref_path.exists() or analisar(sd, corpo, canon)[1]: + continue # sem JSON antigo para comparar, ou pendente pares += 1 referencia = json.loads(ref_path.read_text(encoding="utf-8")) novo = json.loads(serializar(montar(sd, corpo, canon))) @@ -254,16 +242,25 @@ def acervo(canon) -> None: except ExportacaoReprovada as exc: checar(len(exc.pendencias) == len(pend_app), f"{nome}: montar() levanta as mesmas pendências") - # Como a SD fica depois de a pessoa preencher `linha_os`: se só faltava - # isso, o payload completo tem de cumprir o contrato e sair igual nos - # dois caminhos. - com_linhas(sd_cli), com_linhas(sd_app) - if analisar(sd_app, corpo_app, canon)[1]: - continue # pendente por outro motivo — coberto acima + if pend_app: + continue # pendente — coberto acima s_cli = serializar(montar(sd_cli, corpo_cli, canon)) s_app = serializar(montar(sd_app, corpo_app, canon)) - checar(s_cli == s_app, f"{nome}: mesmos bytes (com linha_os preenchido)") - cumpre_contrato(nome, json.loads(s_app), canon) + checar(s_cli == s_app, f"{nome}: mesmos bytes") + payload = json.loads(s_app) + cumpre_contrato(nome, payload, canon) + # O que o .md não declara veio da tabela — e tem de ser o padrão do + # item, não qualquer linha. O que o .md declara vai como está. + por_n = {e["n"]: e["ordem_servico"]["linha"] for e in payload["entregaveis"]} + erradas = [] + for e in sd_app["entregaveis"]: + esperada = (str(e["linha_os"]).strip() if e.get("linha_os") + else linha_da_tabela(canon.linhas_os, e.get("item"))[0]) + if por_n.get(e["n"]) != esperada: + erradas.append((e["n"], por_n.get(e["n"]), esperada)) + da_tabela = sum(1 for e in sd_app["entregaveis"] if not e.get("linha_os")) + checar(not erradas, f"{nome}: linha de OS = declarada no .md ou padrão da tabela " + f"({da_tabela} da tabela)" + (f" — erradas: {erradas}" if erradas else "")) for nome in ESPERADO: if nome not in vistos: checar(False, f"{nome}: consta em ESPERADO mas não existe mais no acervo") @@ -307,13 +304,61 @@ def regras_sinteticas(canon) -> None: got = _pend(canon, entregaveis, topo) checar(sorted(got, key=str) == sorted(esperado, key=str), f"{desc}: {got}") - caso("linha_os ausente", ENT_SEM_LINHA, [("entregavel", 0, "linha_os")]) - caso("linha_os nula", ENT_SEM_LINHA + " linha_os: null\n", [("entregavel", 0, "linha_os")]) + # Linha de OS. Declarada manda; ausente, contrato/linhas-os.yaml dá o + # padrão do item (fallback de 2026-09-04); fora do formato é pendência. + itens_importaveis = sorted(i for i in canon.itens["itens"] if i != "I-01") + checar(canon.linhas_os is not None + and all(linha_da_tabela(canon.linhas_os, i)[0] for i in itens_importaveis), + f"a tabela real tem linha padrão para todo item importável ({', '.join(itens_importaveis)})") + e = montar(*_sd(ENT_SEM_LINHA), canon)["entregaveis"][0] + checar(e["ordem_servico"]["linha"] == linha_da_tabela(canon.linhas_os, "I-02")[0] == "1090-L1", + "linha_os ausente: sai o padrão de I-02 na tabela (1090-L1), sem pendência") + checar(_pend(canon, ENT_SEM_LINHA + " linha_os: null\n") == [], + "linha_os nula: idem — nulo é ausência") caso("linha_os fora do formato", ENT_SEM_LINHA + ' linha_os: "L1"\n', [("entregavel", 0, "linha_os")]) # Só formato: existência, item e status são do banco, na carga. Uma linha - # que o repositório não conhece PASSA aqui, de propósito. + # que o repositório não conhece PASSA aqui, de propósito — e a tabela não + # é régua do que foi declarado. checar(_pend(canon, ENT_SEM_LINHA + ' linha_os: "9999-L7"\n') == [], - "linha_os bem formada que o repositório não conhece passa — quem confere é o banco") + "linha_os bem formada que a tabela não conhece passa — quem confere é o banco") + e = montar(*_sd(ENT_SEM_LINHA + ' linha_os: "1091-L1"\n'), canon)["entregaveis"][0] + checar(e["ordem_servico"]["linha"] == "1091-L1", + "linha declarada NÃO é trocada pelo padrão da tabela — o .md manda") + + # A tabela em outras formas: ausente, ambígua, sem o item, com uma linha só. + sem_tabela = Canonico(canon.itens, None) + caso_t = lambda c, ent: [(p.escopo, p.indice, p.campo) for p in analisar(*_sd(ent), c)[1]] + checar(caso_t(sem_tabela, ENT_SEM_LINHA) == [("entregavel", 0, "linha_os")], + "sem o arquivo linhas-os.yaml, `linha_os` ausente volta a ser pendência (como antes)") + checar(caso_t(sem_tabela, ENT_OK) == [], "sem o arquivo, a linha declarada segue passando") + ambigua = Canonico(canon.itens, preparar_linhas_os( + {"linhas": [{"linha": "1090-L1", "item": "I-02"}, {"linha": "1091-L1", "item": "I-02"}]})) + p = analisar(*_sd(ENT_SEM_LINHA), ambigua)[1] + checar([(x.campo, x.opcoes) for x in p] == [("linha_os", ["1090-L1", "1091-L1"])] + and "sem `padrao`" in p[0].mensagem, + "duas linhas para o item e nenhuma `padrao`: pendência nomeando as candidatas") + so_i05 = Canonico(canon.itens, preparar_linhas_os({"linhas": [{"linha": "2587-L3", "item": "I-05"}]})) + p = analisar(*_sd(ENT_SEM_LINHA), so_i05)[1] + checar([(x.escopo, x.indice, x.campo) for x in p] == [("entregavel", 0, "linha_os")] + and "não tem linha para I-02" in p[0].mensagem, + "item fora da tabela: pendência que diz que a tabela não o tem") + checar(caso_t(so_i05, ENT_SEM_LINHA.replace("I-02", "I-05")) == [], + "item com UMA linha só na tabela: ela é o padrão, sem precisar da marca") + for desc, bruto in ( + ("dois `padrao: true` no mesmo item", + {"linhas": [{"linha": "1090-L1", "item": "I-02", "padrao": True}, + {"linha": "1091-L1", "item": "I-02", "padrao": True}]}), + ("linha repetida", {"linhas": [{"linha": "1090-L1", "item": "I-02"}, {"linha": "1090-L1", "item": "I-05"}]}), + ("linha fora do formato", {"linhas": [{"linha": "L1", "item": "I-02"}]}), + ("`padrao` que não é booleano", {"linhas": [{"linha": "1090-L1", "item": "I-02", "padrao": "sim"}]}), + ("entrada sem item", {"linhas": [{"linha": "1090-L1"}]}), + ("sem a lista `linhas`", {"atualizado_em": "2026-09-04"}), + ): + try: + preparar_linhas_os(bruto) + checar(False, f"tabela malformada ({desc}) reprova na leitura") + except ValueError as exc: + checar(True, f"tabela malformada ({desc}) reprova na leitura — {exc}") caso("I-01 (Licença) não entra pela carga", ENT_OK.replace("I-02", "I-01"), [("entregavel", 0, "item")]) caso("item desconhecido", ENT_OK.replace("I-02", "I-07"), [("entregavel", 0, "item")]) caso("tipo Manutenção para a carga", ENT_OK.replace("Descoberta", "Manutenção"), [("entregavel", 0, "tipo")]) @@ -407,8 +452,8 @@ def schema_valida(canon) -> None: for f in sds_reais(): nome = f.parent.name sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True) - if analisar(com_linhas(sd), corpo, canon)[1]: - continue # não exporta nem com linha_os preenchido — não há JSON a validar + if analisar(sd, corpo, canon)[1]: + continue # não exporta — não há JSON a validar queixa = None try: jsonschema.validate(json.loads(serializar(montar(sd, corpo, canon))), esquema)