Commit inicial: app de exportação da SD + deploy Docker/Coolify
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>
This commit is contained in:
@@ -0,0 +1,519 @@
|
||||
#!/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
|
||||
Reference in New Issue
Block a user