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>
602 lines
27 KiB
Python
602 lines
27 KiB
Python
#!/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
|