Aplicação Flask (enviar SD.md -> corrigir pendências -> exportar JSON), scripts de contrato (scripts/) e infraestrutura de deploy (Dockerfile, docker-compose.yml) para VPS via Coolify. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
520 lines
23 KiB
Python
520 lines
23 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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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`. É o ÚNICO arquivo de dados de que o
|
|
exportador depende. Time-box e tarifa saíram daqui em 2026-09-03: são
|
|
tabelas do sistema de gestão, e é ele que deriva UST e valor na carga.
|
|
|
|
O que NÃO está aqui, de propósito: OS e linhas de OS. Elas são cadastro do
|
|
sistema de gestão e mudam durante o ano; qualquer cópia local envelhece em
|
|
silêncio. O exportador exige `linha_os` declarado no SD.md e só confere o
|
|
formato — existência, item e status quem confere é o banco, na carga.
|
|
"""
|
|
itens: dict
|
|
|
|
|
|
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)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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. Não
|
|
há cópia local disso aqui de propósito: OS e linhas novas entram durante o
|
|
ano, e um de-para em arquivo apontaria linha velha sem ninguém notar.
|
|
Decisão da gestão em 2026-09-03.
|
|
"""
|
|
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
|
|
|
|
|
|
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
|