#!/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