Files
Exporta-SD/scripts/regras_sd.py
T
wanderandClaude Fable 5.1 a4b1374fad 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>
2026-09-04 20:22:15 -03:00

602 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
regras_sd.py — leitura do SD.md, cálculo e dicionários do contrato que o exportador usa.
Por que existe: as mesmas cinco regras estavam escritas duas vezes, uma em cada
script, e já tinham começado a divergir. A pior era a localização da Seção 5 —
o validador perguntava `"## 5." not in corpo` e o exportador casava o regex
`^##\\s+5\\.\\s+`. Um corpo com `## 5.Enquadramento` passava na validação e fazia
o exportador dropar a seção em silêncio: duas respostas para a mesma pergunta,
e a que some é justamente a seção que sustenta a SD contra reclassificação.
Regra deste módulo: aqui mora o CÁLCULO e a LEITURA. Quem decide a severidade
(ERRO, AVISO, exceção) é o chamador — o validador reporta, o exportador levanta,
e a aplicação web mostra na tela. Misturar as duas coisas foi o que produziu
mensagens divergentes para a mesma regra.
"""
from __future__ import annotations
import math
import re
import sys
from datetime import date
from pathlib import Path
from typing import NamedTuple
try:
import yaml
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
# ---------------------------------------------------------------------------
# Canônico
# ---------------------------------------------------------------------------
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`. 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.
`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, 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
# ---------------------------------------------------------------------------
# Leitura do SD.md
# ---------------------------------------------------------------------------
def ler_sd_texto(texto: str, *, preservar_comentarios: bool = False) -> tuple[dict, str]:
"""Separa o frontmatter YAML da prosa. Um parser só, para os dois scripts.
Devolve (frontmatter, corpo). O frontmatter contém APENAS chaves do
frontmatter: nada de `_corpo` injetado dentro dele. A versão antiga do
validador fazia essa injeção, e ela era inofensiva enquanto o mapa só era
lido. Deixou de ser: a aplicação web regrava esse mesmo objeto no disco, e
um `_corpo` ali dentro escreveria o markdown inteiro como escalar YAML
dentro do frontmatter — corrupção silenciosa de um SD.md do repositório.
`preservar_comentarios=True` devolve um CommentedMap do ruamel em vez de um
dict comum. É subclasse de dict, então validar e exportar funcionam sem
saber a diferença; o que muda é que o round-trip de escrita preserva
comentários e ordem. Os comentários do frontmatter carregam decisão de
contrato — ver o campo `estado` da P2·SD10, quatro linhas de correção
datada que um safe_dump apagaria sem deixar rastro.
"""
linhas = texto.splitlines()
# Split por LINHA exatamente igual a "---", nunca por substring: comentários
# de separação (# ------) dentro do frontmatter contêm "---" e truncariam o
# bloco silenciosamente, produzindo "a SD não tem entregáveis".
if not linhas or linhas[0].strip() != "---":
raise ValueError("SD sem frontmatter YAML — a primeira linha deve ser '---'")
fim = next((i for i, l in enumerate(linhas[1:], start=1) if l.strip() == "---"), None)
if fim is None:
raise ValueError("frontmatter YAML não fechado — falta a linha '---' de fecho")
bruto = "\n".join(linhas[1:fim])
corpo = "\n".join(linhas[fim + 1:])
if preservar_comentarios:
from ruamel.yaml import YAML
dados = YAML().load(bruto)
else:
dados = yaml.safe_load(bruto)
if not isinstance(dados, dict):
raise ValueError("frontmatter não é um mapeamento YAML")
return dados, corpo
def ler_sd_arquivo(caminho: Path, *, preservar_comentarios: bool = False) -> tuple[dict, str]:
return ler_sd_texto(Path(caminho).read_text(encoding="utf-8"),
preservar_comentarios=preservar_comentarios)
def secao(corpo: str, numero: str) -> str | None:
"""Extrai uma seção '## N. Título' do corpo, sem o cabeçalho.
Única resposta para "esta seção existe?" e "qual é o texto dela?". O
validador pergunta a primeira, o exportador a segunda, e antes deste módulo
cada um usava um critério diferente.
"""
pad = re.compile(rf"^##\s+{re.escape(numero)}\.\s+.*$", re.M)
m = pad.search(corpo)
if not m:
return None
resto = corpo[m.end():]
prox = re.search(r"^##\s+\d", resto, re.M)
texto = (resto[:prox.start()] if prox else resto).strip()
return texto or None
# Títulos das seções do corpo, como o _template/SD.md os escreve. Usados só
# quando definir_secao() precisa CRIAR a seção — uma existente mantém o título
# que tem.
TITULOS_SECAO = {
"1": "Objetivo",
"2": "Contexto e escopo da demanda",
"3": "Quadro de entregáveis",
"4": "Detalhamento dos entregáveis",
"5": "Enquadramento no Termo de Referência",
"6": "Fora de escopo",
}
def definir_secao(corpo: str, numero: str, texto: str) -> str:
"""O inverso de secao(): devolve o corpo com a seção N valendo `texto`.
Se a seção existe, só o conteúdo entre o cabeçalho dela e o próximo `## N`
muda — o cabeçalho fica como estava. Se não existe (a P2·SD10 não tem
Seção 1: vai do título direto à 5), o cabeçalho é criado antes da primeira
seção de número maior, ou no fim do corpo quando não há nenhuma. O mesmo
regex de secao() localiza o cabeçalho: duas respostas para "onde fica a
Seção N" é exatamente o defeito que este módulo existe para não repetir.
"""
corpo = corpo or ""
texto = (texto or "").strip()
pad = re.compile(rf"^##\s+{re.escape(numero)}\.\s+.*$", re.M)
m = pad.search(corpo)
if m:
resto = corpo[m.end():]
prox = re.search(r"^##\s+\d", resto, re.M)
fim = m.end() + (prox.start() if prox else len(resto))
return corpo[:m.end()] + "\n\n" + texto + "\n\n" + corpo[fim:].lstrip("\n")
cabecalho = f"## {numero}. {TITULOS_SECAO.get(numero, '')}".rstrip()
bloco = f"{cabecalho}\n\n{texto}\n\n"
# Antes da primeira seção de número MAIOR — mantém a ordem 1..6 do template.
for outro in re.finditer(r"^##\s+(\d+)\.\s+.*$", corpo, re.M):
if int(outro.group(1)) > int(numero):
ini = outro.start()
return corpo[:ini].rstrip("\n") + "\n\n" + bloco + corpo[ini:]
return corpo.rstrip("\n") + "\n\n" + bloco
# Rótulos da tabela da Seção 5, exatamente como o _template/SD.md os escreve.
_ROTULO_TR = re.compile(
r"^\|\s*\*\*(Item do TR|Descrição do item|Aderência desta SD)\*\*\s*\|(.*?)\|?\s*$")
_CODIGO_ITEM = re.compile(r"\bI-\d{2}\b")
_BULLET = re.compile(r"^\s{0,3}[-*]\s+(.*)$")
_QUEBRA_HTML = re.compile(r"<br\s*/?>", re.I)
# Marcador de lista no INÍCIO do texto: '•', ou '-'/'*' seguido de espaço.
_MARCADOR = re.compile(r"^(?:•|[-*](?=\s))\s*")
def _texto_de_celula(v: str) -> str:
"""Uma linha só, sem o marcador de bullet que a célula às vezes traz.
O '*' só conta como marcador quando vem seguido de espaço — nunca o '**' de
negrito. Um lstrip('•*- ') ingênuo comia a abertura do negrito e deixava o
fecho órfão: os cinco bullets da P5·SD24 começam com '**Análise das
Necessidades...**' e saíam como 'Análise das Necessidades...**'. Texto
remendado é pior que texto ausente, porque parece certo.
"""
return _MARCADOR.sub("", " ".join(str(v).split())).strip()
def _bullets_da_prosa(linhas: list[str]) -> list[str]:
"""Bullets markdown de um bloco, com a indentação pendurada recolada.
Os bullets da Seção 5 quebram em várias linhas com recuo de dois espaços
(ver a P7·SD31). Sem colar a continuação, cada bullet chegava truncado na
primeira quebra de linha — e truncado no meio de uma frase de enquadramento
é pior que ausente, porque parece completo.
"""
saida: list[str] = []
atual: str | None = None
for l in linhas:
m = _BULLET.match(l)
if m:
if atual is not None:
saida.append(_texto_de_celula(atual))
atual = m.group(1)
elif atual is not None:
if l.strip() and l[:1] in (" ", "\t"):
atual += " " + l.strip()
else:
# Linha em branco ou parágrafo à margem fecha a lista: em SD31 e
# SD24 o que vem depois dos bullets é prosa de fecho ("Nenhum
# entregável foge do item padrão..."), não aderência.
saida.append(_texto_de_celula(atual))
atual = None
if atual is not None:
saida.append(_texto_de_celula(atual))
return [b for b in saida if b]
def enquadramento_tr(texto: str | None, itens_canon: dict) -> list[dict]:
"""A tabela da Seção 5, como dado. Uma entrada por item que a SD toca.
Recebe o texto JÁ recortado por secao(corpo, "5"), nunca o corpo inteiro:
quem responde "onde fica a Seção 5?" é secao(), e um segundo ponto fazendo a
mesma pergunta é exatamente como nasceu a divergência descrita na docstring
deste módulo.
Esta é a ÚNICA saída da Seção 5. Houve uma fase em que ela também subia como
texto cru colado em `sd.contexto` — o conteúdo chegava, o dado não; esse
espelho foi removido, e `sd.contexto` hoje recebe só a Seção 2. O schema já
reservava `_governanca.enquadramento_tr` para a forma estruturada desde a
v2.0.
O acervo escreve a aderência de duas maneiras, e as duas são aceitas:
· bullets '•' dentro da própria célula, separados por <br> (P2·SD8);
· a célula como ponteiro ("ver bullets abaixo") e os bullets em prosa
logo depois da tabela (P7·SD31, P7·SD32 e as demais).
Não julga o TAMANHO da lista. Quem reprova aderência fora de 3..5 é o
schema, e o número real precisa aparecer no JSON para o defeito ser visível:
a P7·SD32 tem um bloco com 2 bullets, e emitir 2 é o que faz alguém escrever
o terceiro. Bloco sem aderência nenhuma sai com lista vazia, de propósito —
omitir a entrada seria dropar em silêncio a seção que sustenta a SD.
"""
if not texto:
return []
linhas = texto.splitlines()
aberturas = [i for i, l in enumerate(linhas)
if (m := _ROTULO_TR.match(l.strip())) and m.group(1) == "Item do TR"]
if not aberturas:
# Seção 5 sem a tabela: a P4·SD20 é prosa livre com `###`, e tem duas
# tabelas de OUTRO assunto que o recorte de secao() traz junto. Ancorar
# em "Item do TR" — e não em "linha de tabela" — é o que as ignora.
return []
saida = []
for ini, fim in zip(aberturas, aberturas[1:] + [len(linhas)]):
bloco = linhas[ini:fim]
campos: dict[str, str] = {}
for l in bloco:
m = _ROTULO_TR.match(l.strip())
if m and m.group(1) not in campos:
campos[m.group(1)] = " ".join(m.group(2).split())
cod = _CODIGO_ITEM.search(campos.get("Item do TR", ""))
if not cod:
continue
item = cod.group(0)
if item not in itens_canon:
raise ValueError(
f"Seção 5: item {item!r} na linha `Item do TR` não existe no contrato "
f"canônico (válidos: {', '.join(sorted(itens_canon))}). "
"Ver clientes/ses-mg/contrato/itens.yaml")
entrada = {
"item": item,
# Derivado, nunca transcrito: a célula funde código e título, e o
# acervo tem três formatos para ela — com aspas, sem aspas, e com
# negrito mais CATMAS pelo meio. O título literal mora no canônico,
# e o próprio schema diz "LITERAL de contrato/itens.yaml".
"titulo_literal": itens_canon[item]["titulo_literal"],
}
if campos.get("Descrição do item"):
entrada["descricao_item"] = campos["Descrição do item"]
celula = campos.get("Aderência desta SD", "")
partes = [_texto_de_celula(p) for p in _QUEBRA_HTML.split(celula)]
partes = [p for p in partes if p]
if len(partes) >= 2:
ader = partes # bullets dentro da célula
else:
ader = _bullets_da_prosa(bloco) or partes
entrada["aderencia"] = ader
saida.append(entrada)
return saida
# ---------------------------------------------------------------------------
# Conversões
# ---------------------------------------------------------------------------
def como_iso(v) -> str | None:
if v is None:
return None
if isinstance(v, date):
return v.isoformat()
return str(v)
def como_data(v) -> date | None:
"""Converte para date, ou levanta ValueError. Quem reporta é o chamador.
Sem efeito colateral de propósito: a versão antiga do validador escrevia o
ERRO no relatório de dentro da conversão, o que a tornava inutilizável fora
dele. O exportador precisava da mesma conversão e acabou com a sua própria.
"""
if v is None:
return None
if isinstance(v, date):
return v
return date.fromisoformat(str(v))
# ---------------------------------------------------------------------------
# Regras derivadas
# ---------------------------------------------------------------------------
def semanas_por_datas(inicio: date, fim: date) -> int:
"""Semanas faturáveis entre duas datas — a MESMA regra do sistema de destino.
max(1, ceil(dias/7)), com `dias` exclusivo (subtração de datas, como a
diferença de getTime() no sistema): 0-7d → 1, 8-14d → 2, 15-21d → 3,
22-28d → 4.
NÃO serve para prazo_calendario_semanas: aquele é duração de CALENDÁRIO,
decimal de propósito — a P2·SD8 tem 26 semanas de time-box em 4,6 de
calendário, valor que o schema cita nominalmente. Grandezas diferentes,
fórmulas diferentes, de caso pensado. Já houve quem tentasse unificar as
duas; ver o comentário no cálculo do prazo em exporta_sd.montar().
"""
return max(1, math.ceil((fim - inicio).days / 7))
def conferir_semanas(e: dict, ini: str | None, fim: str | None) -> tuple[str, str] | None:
"""Confere as semanas DECLARADAS contra as datas.
Devolve (severidade, mensagem) ou None se estiver coerente. Severidade
'DIVERGE' = erro comprovado (declarado ≠ calculado); 'SEM-DATA' = não deu
para conferir. Só a primeira reprova em --estrito.
`numero_semanas` é o único fator de duração declarado à mão — no schema não
tem x-derivado, é integer de 1 a 4. Só que o sistema de destino RECALCULA as
semanas a partir das datas na importação, por max(1, ceil(dias/7)). Quando
os dois discordam, a UST e o valor faturado do entregável divergem do que o
sistema vai calcular, e ninguém percebe: 4 dos 45 entregáveis datados
divergiam, com oscilação de -R$ 22 mil a +R$ 43 mil POR LINHA (o líquido de
+R$ 20 mil esconde isso, e faturamento é por linha, não por saldo).
Só CONFERE, nunca corrige, e é de propósito: semanas é dado declarado, e o
teto de 4 da R4 vem de documento formalizado (P2·SD12 V2). A P2·SD8 n8 tem
31 dias, que dariam 5 — derivar emitiria JSON fora do próprio schema
(numero_semanas.maximum = 4). Reconciliar é decisão humana, não do script.
Vive aqui, e não no exportador, porque a conferência precisa acontecer na
Fase 1: enquanto ela só rodava na exportação, a divergência aparecia DEPOIS
de o usuário já ter confirmado os dados.
"""
dec = e.get("semanas")
if not (ini and fim):
# Sem data não há o que conferir. Reportar como não verificável em vez
# de assumir 1: a P4·SD20 tem cinco entregáveis assim, e silenciar aqui
# carimbaria como conferido o que ninguém conferiu.
return ("SEM-DATA",
f"entregável {e.get('n')}: sem datas — semanas declaradas ({dec}) não verificáveis")
try:
a, b = date.fromisoformat(str(ini)), date.fromisoformat(str(fim))
except ValueError:
return ("SEM-DATA",
f"entregável {e.get('n')}: datas ilegíveis ({ini!r} → {fim!r}) — não verificáveis")
can = semanas_por_datas(a, b)
if can != dec:
return ("DIVERGE",
f"entregável {e.get('n')}: {(b - a).days} dias => {can} semanas pela regra do "
f"sistema, mas o SD declara {dec} — UST e valor vão divergir na importação")
return None
def checar_dependencias(dep) -> str | None:
"""`dependencias` precisa ser um mapa por dono. Devolve a queixa, ou None.
A P4·SD20 trazia uma lista solta aqui e derrubava o exportador com um
AttributeError sem dono. Uma mensagem só para os dois scripts: antes o
validador dizia uma coisa e o exportador outra sobre a mesma regra.
"""
if dep is None or isinstance(dep, dict):
return None
return (f"`dependencias` precisa ser um mapa com ses_mg/isis/terceiros, não "
f"{type(dep).__name__}. Sem a separação por dono não dá para dizer "
"quem tem poder de veto sobre o cronograma. "
"Ver clientes/ses-mg/projetos/_template/SD.md")
# ---------------------------------------------------------------------------
# Contrato da carga — CONTRATO-JSON-V2.md (`npm run import:sd`, epic 92)
# ---------------------------------------------------------------------------
# O que está abaixo é o que a carga do sistema de gestão exige do JSON, escrito
# uma vez para o exportador e para quem mais quiser conferir. Grafia EXATA, com
# acento e na caixa indicada: a carga não normaliza ("Em execucao" reprova por
# decisão, §6). Rótulo, e não código do banco — o parser do importador traduz
# "Em Execução" para EM_EXECUCAO; confirmado com a gestão em 2026-09-03.
STATUS_SD = ("Planejado", "Em andamento", "Em execução", "Entregue", "Aceito")
# Doze valores, no masculino desde a epic 82. "Planejado" NÃO vale para
# entregável — o esqueleto da §11 do contrato o usa por engano.
STATUS_ENTREGAVEL = (
"Rascunho", "Emitido", "Em Execução", "Documentado", "Aguardando Validação",
"Em Revisão", "Aprovado", "Glosado", "Encerrado", "Cancelado",
"Aguardando Pagamento", "Pago",
)
TIPOS_IMPORTAVEIS = ("Descoberta", "Design", "Arquitetura", "Construção")
# I-01 é a licença SaaS: preço fixo anual, sem UST. Não entra por esta carga
# (Regra 11 da epic 92) — está no itens.yaml para o exportador saber reprovar.
ITENS_FORA_DA_CARGA = ("I-01",)
# Traduzem, mas PARAM a carga na aritmética (Regra 11): não entram por JSON.
TIPOS_QUE_PARAM_A_CARGA = ("Manutenção", "Licença")
# Teto da janela de um entregável, em dias — 4 semanas. Regra da carga, não do
# sistema: janela maior tem de ser quebrada em mais de um entregável no SD.md.
JANELA_MAXIMA_DIAS = 28
# O repositório mantém o estado na SD; o sistema, no entregável — e vai além do
# aceite, até o pagamento. Estado desconhecido NÃO cai num default: o exportador
# reporta, com esta lista como opções.
ESTADO_SD_PARA_ENTREGAVEL = {
"rascunho": "Rascunho",
"planejada": "Rascunho",
"parada": "Rascunho",
"bloqueada": "Rascunho",
"emitida": "Emitido",
"em_execucao": "Em Execução",
"documentada": "Documentado",
"aguardando_validacao": "Aguardando Validação",
"validado": "Aprovado",
}
ESTADO_SD_PARA_STATUS_SD = {
"rascunho": "Planejado",
"planejada": "Planejado",
"parada": "Planejado",
"bloqueada": "Planejado",
"emitida": "Em andamento",
"em_execucao": "Em andamento",
"documentada": "Em andamento",
"aguardando_validacao": "Em andamento",
"validado": "Entregue", # decisão da gestão em 2026-09-03 — não "Concluído"
}
ESTADOS_SD = tuple(ESTADO_SD_PARA_STATUS_SD)
# Nomes de perfil como estão no CADASTRO do cliente no sistema de gestão. O
# itens.yaml escreve dois deles como o TR escreve; o cadastro, não. O importador
# traduz esses dois aliases (§4.1), mas emitir já o nome do cadastro tira a
# dependência de uma tradução alheia. itens.yaml fica como está — é transcrição
# do TR, e o TR é a fonte dele.
PERFIL_ALIASES = {
"Especialista de Inteligência (Cientistas dados/Processos)":
"Especialista de Inteligência (Cientista de Dados)",
"Especialista de infraestrutura": "Especialista de Infraestrutura",
}
PERFIS_DO_CADASTRO = (
"Especialista de Inteligência (Cientista de Dados)",
"Analista de Negócio/Processo",
"Especialista de Negócio (Saúde)",
"Especialista de Tecnologia / Arquiteto",
"Gerente de Projeto",
"Desenvolvedor / Engenheiro de Dados",
"Especialista de Infraestrutura",
"Scrum Master",
)
def perfil_do_cadastro(nome) -> str:
"""O nome do perfil como o cadastro do cliente o conhece."""
nome = " ".join(str(nome or "").split())
return PERFIL_ALIASES.get(nome, nome)
# Formato de `ordem_servico.linha` (CONTRATO-JSON-V2 §5): "{código-da-OS}-L{n}".
_LINHA_OS = re.compile(r"^\d+-L\d+$")
def conferir_formato_linha_os(linha) -> str | None:
"""A queixa contra o `linha_os` declarado, ou 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. 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 "
"emite a SD sabe qual OS a lastreia. Copie da tela da OS no sistema, no "
"formato {OS}-L{n} (ex.: 1090-L1)")
if not _LINHA_OS.match(str(linha).strip()):
return f"linha de OS {linha!r} fora do formato `{{OS}}-L{{n}}` (ex.: 1090-L1)"
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