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:
+93
-48
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user