#!/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"
", 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
(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