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
+94 -12
View File
@@ -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