Compare commits

...
3 Commits
Author SHA1 Message Date
wanderandClaude Opus 5 382567b929 Tela de login: credencial única, sem banco
Toda rota passa por um before_request que redireciona para /login quem não
tem sessão — inclusive os POSTs, então /exportar e /salvar-sd sem cookie não
chegam ao handler. Ficam de fora só a própria entrada e /static (a tela de
login precisa da folha de estilo).

Credencial única em constante (admin@iasis.com.br / admin123), comparada com
compare_digest nos dois campos e com a mesma mensagem para usuário errado e
senha errada — dizer qual dos dois falhou entrega metade da credencial. Sem
banco, sem cadastro e sem recuperação de senha: a app é ferramenta interna de
uso individual, e isto existe para o upload e o writeback não ficarem abertos
a quem alcançar a porta quando ela roda hospedada.

SECRET_KEY não tem default assado na imagem — chave fixa numa imagem vale para
toda cópia dela. Sem a variável, o servidor sorteia uma por processo e o login
cai a cada restart, que é a mesma vida útil que a sessão da SD já tem; é seguro
porque o gunicorn roda com -w 1. APP_USUARIO/APP_SENHA seguem a convenção do
Dockerfile: default lá, compose só espelha.

O Sair encerra a sessão do navegador sem descartar a SD em memória — é a mesma
máquina, o mesmo processo. E o teste_e2e faz login uma vez no test client,
senão todo request dele voltaria 302.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 02:44:04 -03:00
wanderandClaude Sonnet 5 62ec768d0c Deploy: variáveis de ambiente configuráveis + docs dos dois modos do Coolify
TZ (com tzdata), GUNICORN_TIMEOUT e GUNICORN_LOG_LEVEL agora têm default no
Dockerfile e podem ser sobrescritos via Coolify → Environment Variables ou
.env (modelo em .env.example). TZ corrige o carimbo "exportada em"
(sessao.marcar_exportada), que sem isso saía em UTC. Workers continua
literal em -w 1, de propósito — não é variável.

Cabeçalhos do Dockerfile e docker-compose.yml reescritos para descrever os
dois modos de deploy no Coolify (Application/Dockerfile, em produção desde
04/09, e Docker Compose) e o caminho de volume que de fato funciona:
Directory mount em /data/coolify/applications/<uuid>/clientes, dados
enviados por scp da pasta ses-mg/.

.gitattributes fixa LF também em Dockerfile, docker-compose.yml e
.env.example — o autocrlf do Windows já tinha reescrito o Dockerfile para
CRLF uma vez nesta máquina.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-04 22:00:34 -03:00
wanderandClaude Fable 5.1 a4b1374fad Linha de OS: tabela linhas-os.yaml como fallback quando o SD.md não declara
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>
2026-09-04 20:22:15 -03:00
17 changed files with 627 additions and 155 deletions
+3
View File
@@ -17,3 +17,6 @@ scripts/backlog
Dockerfile Dockerfile
docker-compose.yml docker-compose.yml
.dockerignore .dockerignore
.env
.env.example
.gitattributes
+42
View File
@@ -0,0 +1,42 @@
# Variáveis da Exportação da SD. Copie para .env (gitignored) para rodar com o
# compose; no Coolify em modo Application, cadastre em Environment Variables.
# Tudo tem default no Dockerfile — só declare o que quer mudar.
# ── Lidas DENTRO do container (valem nos dois modos do Coolify) ──────────────
# Porta do gunicorn dentro do container. No Coolify, a porta exposta da app
# tem de ser a mesma.
PORT=5000
# Fuso do carimbo "exportada em" (sessao.marcar_exportada usa datetime.now()).
TZ=America/Sao_Paulo
# Segundos por request. Upload + análise de uma SD leva bem menos que isso;
# subir só se o painel mostrar "WORKER TIMEOUT".
GUNICORN_TIMEOUT=120
# debug | info | warning | error
GUNICORN_LOG_LEVEL=info
# ── Porta de entrada (tela de login) ────────────────────────────────────────
# Credencial única da app. Não há banco nem cadastro: estes dois valores SÃO os
# usuários da aplicação. Os defaults do código são admin@iasis.com.br/admin123 —
# declare aqui só para trocar a credencial no deploy.
#APP_USUARIO=admin@iasis.com.br
#APP_SENHA=admin123
# Assina o cookie de sessão do login. Sem isto, cada start sorteia uma chave e
# o login cai a cada deploy/restart. Gere com:
# python -c "import secrets; print(secrets.token_hex(32))"
#SECRET_KEY=
# ── Lidas pelo COMPOSE ao montar volumes (só no Modo 2 / local) ─────────────
# No Modo 1 (Application) não têm efeito: o volume é o Directory mount da UI.
# Pasta do host com os dados do cliente (tem de conter ses-mg/contrato/itens.yaml).
# Fora da pasta que o Coolify clona — ela é recriada a cada deploy.
#CLIENTES_DIR=/dados/exporta-sd/clientes
# Saída da linha de comando (exporta_sd.py --dir). Opcional.
#DERIVADOS_DIR=/dados/exporta-sd/_derivados
+5
View File
@@ -3,3 +3,8 @@
# autocrlf=true do Git no Windows reescreve o arquivo na próxima operação que # autocrlf=true do Git no Windows reescreve o arquivo na próxima operação que
# o toque e o build local passa a falhar mesmo com a imagem correta. # o toque e o build local passa a falhar mesmo com a imagem correta.
*.sh text eol=lf *.sh text eol=lf
# Mesma razão para os arquivos de deploy: são lidos por ferramentas Linux e o
# autocrlf já reescreveu o Dockerfile para CRLF uma vez nesta máquina.
Dockerfile text eol=lf
docker-compose.yml text eol=lf
.env.example text eol=lf
+5 -1
View File
@@ -8,7 +8,8 @@ scripts/backlog/
# Dados do cliente: contrato, projetos e SDs. Fora do git por decisão de # Dados do cliente: contrato, projetos e SDs. Fora do git por decisão de
# 04/09/2026. No deploy, entram por volume montado em /app/clientes — o # 04/09/2026. No deploy, entram por volume montado em /app/clientes — o
# app exige clientes/ses-mg/contrato/itens.yaml em tempo de execução. # app exige clientes/ses-mg/contrato/itens.yaml em tempo de execução, e usa
# contrato/linhas-os.yaml (opcional) como fallback da linha de OS.
clientes/ clientes/
# Documentos de trabalho na RAIZ (contrato, propostas, prompts, leiame). # Documentos de trabalho na RAIZ (contrato, propostas, prompts, leiame).
@@ -17,3 +18,6 @@ clientes/
# Lock de sessão local do Claude Code — efêmero (PID, sessionId), não é config do projeto. # Lock de sessão local do Claude Code — efêmero (PID, sessionId), não é config do projeto.
.claude/scheduled_tasks.lock .claude/scheduled_tasks.lock
# Variáveis locais do compose — .env.example é o modelo versionado.
.env
+47 -9
View File
@@ -1,11 +1,26 @@
# Imagem da aplicação de Exportação da SD — deploy em VPS via painel Coolify. # Imagem da aplicação de Exportação da SD — deploy em VPS via painel Coolify.
# #
# O que NÃO entra na imagem, e por que: clientes/. O app lê # O que NÃO entra na imagem, e por que: clientes/. O app lê
# clientes/ses-mg/contrato/itens.yaml em tempo de execução (regras_sd.carregar_canonico) # clientes/ses-mg/contrato/itens.yaml em tempo de execução (regras_sd.carregar_canonico),
# e grava SD.md de volta em clientes/ses-mg/projetos/ (escrita_sd.gravar). Esses # consulta contrato/linhas-os.yaml se existir (fallback da linha de OS; ausente,
# são dados do cliente, versionados fora do git — entram por VOLUME montado em # `linha_os` é exigido no SD.md) e grava SD.md de volta em
# /app/clientes. Sem esse volume, a imagem sobe e a tela inicial responde, mas o # clientes/ses-mg/projetos/ (escrita_sd.gravar). Esses são dados do cliente,
# primeiro upload falha com "Arquivo canônico ausente". # versionados fora do git — entram por VOLUME montado em /app/clientes. Sem esse
# volume, a imagem sobe e a tela inicial responde, mas o primeiro upload falha
# com "Arquivo canônico ausente". E sem linhas-os.yaml no volume, o VPS se
# comporta diferente do local: o fallback desliga e `linha_os` vira pendência.
#
# DOIS MODOS de deploy no Coolify — a imagem é a mesma, muda onde se configura:
#
# 1. Application (Dockerfile) — o que está em produção (2026-09-04). O Coolify
# IGNORA o docker-compose.yml. Volume: Persistent storage → Add mount →
# "Directory mount", Source /data/coolify/applications/<uuid>/clientes
# (o Coolify sugere esse caminho; ele sobrevive a redeploy) → Destination
# /app/clientes. Variáveis: aba Environment Variables. Réplicas: 1.
# 2. Docker Compose — usa o docker-compose.yml; volume e variáveis vêm de lá.
#
# Por isso TODO default de variável mora AQUI (ENV + ${VAR:-default} no CMD),
# e o compose só espelha: variável que existe só no compose não existe no VPS.
# #
# Build local: docker build -t exporta-sd . # Build local: docker build -t exporta-sd .
# Run local: docker run --rm -p 5000:5000 -v "$PWD/clientes:/app/clientes" exporta-sd # Run local: docker run --rm -p 5000:5000 -v "$PWD/clientes:/app/clientes" exporta-sd
@@ -15,18 +30,40 @@ FROM python:3.13-slim
# PYTHONUNBUFFERED: log do gunicorn sai na hora no painel do Coolify. # PYTHONUNBUFFERED: log do gunicorn sai na hora no painel do Coolify.
# LANG/LC_ALL em UTF-8: identificadores de SD trazem "·" (P2·SD8) e o # LANG/LC_ALL em UTF-8: identificadores de SD trazem "·" (P2·SD8) e o
# escrita_sd monta nome de arquivo a partir deles. # escrita_sd monta nome de arquivo a partir deles.
# TZ: sessao.marcar_exportada carimba datetime.now() na tela ("exportada em");
# sem isto o container está em UTC e o horário sai 3h adiantado.
#
# Variáveis que o operador pode sobrescrever (Coolify → Environment Variables,
# ou .env com o compose — ver .env.example). Os defaults valem nos dois modos:
# PORT porta do gunicorn dentro do container
# TZ fuso do carimbo de exportação
# GUNICORN_TIMEOUT segundos por request (upload + análise da SD)
# GUNICORN_LOG_LEVEL debug | info | warning | error
# APP_USUARIO e-mail da credencial única da tela de login
# APP_SENHA senha dessa credencial
# SECRET_KEY assina o cookie de sessão — SEM default aqui de
# propósito: chave fixa assada na imagem é pior que
# nenhuma (vale para toda cópia dela). Sem a variável, o
# servidor.py sorteia uma por processo e o login cai a
# cada restart; cadastre no Coolify para o login durar.
ENV PYTHONUNBUFFERED=1 \ ENV PYTHONUNBUFFERED=1 \
PYTHONDONTWRITEBYTECODE=1 \ PYTHONDONTWRITEBYTECODE=1 \
LANG=C.UTF-8 \ LANG=C.UTF-8 \
LC_ALL=C.UTF-8 \ LC_ALL=C.UTF-8 \
PORT=5000 PORT=5000 \
TZ=America/Sao_Paulo \
GUNICORN_TIMEOUT=120 \
GUNICORN_LOG_LEVEL=info \
APP_USUARIO=admin@iasis.com.br \
APP_SENHA=admin123
WORKDIR /app WORKDIR /app
# gosu: o entrypoint precisa iniciar como root (só root faz chown no volume # gosu: o entrypoint precisa iniciar como root (só root faz chown no volume
# recém-montado) e depois derrubar privilégio para `sd` antes do gunicorn. # recém-montado) e depois derrubar privilégio para `sd` antes do gunicorn.
# tzdata: zoneinfo para o TZ acima ter efeito (a slim não garante que venha).
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends gosu \ && apt-get install -y --no-install-recommends gosu tzdata \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
# Dependências em camada própria: mudar código não reinstala pacote. # Dependências em camada própria: mudar código não reinstala pacote.
@@ -65,8 +102,9 @@ HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
# é a global ATUAL no processo do servidor.py (uma SD por vez, em memória). # é a global ATUAL no processo do servidor.py (uma SD por vez, em memória).
# Com dois workers, dois requests do MESMO usuário caem em processos # Com dois workers, dois requests do MESMO usuário caem em processos
# diferentes e a sessão desaparece de forma aleatória. Pelo mesmo motivo, o # diferentes e a sessão desaparece de forma aleatória. Pelo mesmo motivo, o
# número de réplicas no Coolify tem de ficar em 1. # número de réplicas no Coolify tem de ficar em 1 — e é por isso que o número
# de workers NÃO é variável de ambiente: não existe valor certo além de 1.
# #
# --chdir app + servidor:app: o servidor.py insere ../scripts no sys.path a # --chdir app + servidor:app: o servidor.py insere ../scripts no sys.path a
# partir de __file__, então funciona igual sob gunicorn e sob `python app/servidor.py`. # partir de __file__, então funciona igual sob gunicorn e sob `python app/servidor.py`.
CMD ["sh", "-c", "exec gunicorn --chdir app servidor:app -w 1 -b 0.0.0.0:${PORT:-5000} --timeout 120 --access-logfile - --error-logfile -"] CMD ["sh", "-c", "exec gunicorn --chdir app servidor:app -w 1 -b 0.0.0.0:${PORT:-5000} --timeout ${GUNICORN_TIMEOUT:-120} --log-level ${GUNICORN_LOG_LEVEL:-info} --access-logfile - --error-logfile -"]
+95 -1
View File
@@ -25,18 +25,28 @@ botão nenhum.
Estado em memória de processo, uma SD por vez: é ferramenta local de uso Estado em memória de processo, uma SD por vez: é ferramenta local de uso
individual. Reiniciar o servidor descarta a sessão — o SD.md de origem continua individual. Reiniciar o servidor descarta a sessão — o SD.md de origem continua
intacto no disco, e nada é gravado sem ação explícita (Salvar SD.md). intacto no disco, e nada é gravado sem ação explícita (Salvar SD.md).
Na frente de tudo isso há uma tela de login com credencial única, fixa na
configuração — sem banco, sem cadastro, sem recuperação de senha. Ela não
divide dados entre pessoas (a sessão da SD continua sendo uma só, do processo);
serve para que upload, writeback e download não fiquem abertos a quem alcançar
a porta quando a app roda hospedada.
""" """
from __future__ import annotations from __future__ import annotations
import hmac
import io import io
import os
import secrets
import sys import sys
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from flask import Flask, redirect, render_template, request, send_file, url_for from flask import (Flask, redirect, render_template, request, send_file, session,
url_for)
import escrita_sd import escrita_sd
import sessao as S import sessao as S
@@ -59,6 +69,84 @@ app.jinja_env.globals["dias"] = lambda a, b: (date.fromisoformat(b) - date.fromi
ATUAL: dict = {"sessao": None, "aviso": None, "erro": None} ATUAL: dict = {"sessao": None, "aviso": None, "erro": None}
# ---------------------------------------------------------------------------
# Porta de entrada
# ---------------------------------------------------------------------------
# Credencial única, fixa no código. Não existe banco, cadastro nem recuperação
# de senha: a app é ferramenta interna de uso individual, e a autenticação aqui
# serve para não deixar o upload e o Salvar SD.md abertos a quem alcançar a
# porta. As variáveis de ambiente existem só para trocar a credencial no deploy
# sem editar código — os defaults abaixo são a credencial acordada.
USUARIO = os.environ.get("APP_USUARIO", "admin@iasis.com.br").strip().lower()
SENHA = os.environ.get("APP_SENHA", "admin123")
# Assina o cookie de sessão. Sem SECRET_KEY no ambiente, sorteia uma por
# processo: reiniciar o servidor derruba o login — a mesma vida útil que a
# sessão da SD já tem (ver docstring), então nada se perde além do login.
# Vale porque o gunicorn roda com -w 1 (ver Dockerfile); com mais workers,
# cada um assinaria com uma chave diferente e SECRET_KEY passaria a ser
# obrigatória.
app.secret_key = os.environ.get("SECRET_KEY") or secrets.token_hex(32)
# Rotas que respondem sem login: a tela de entrada (`login`), o POST que a
# submete (`entrar` — endpoint com outro nome, e sem ele o próprio login bate
# no portão) e `static`, porque a tela de entrada precisa da folha de estilo.
LIVRES = {"login", "entrar", "static"}
def autenticado() -> bool:
return session.get("autenticado") is True
@app.before_request
def exigir_login():
"""O portão. Roda antes de qualquer rota — inclusive dos POSTs, então um
POST direto em /exportar ou /salvar-sd sem cookie não chega ao handler."""
if request.endpoint in LIVRES or autenticado():
return None
return redirect(url_for("login"))
def credencial_confere(email: str, senha: str) -> bool:
"""compare_digest nos dois campos: comparação de tempo constante, e sempre
os dois — sair mais cedo no e-mail errado contaria qual dos dois falhou."""
ok_email = hmac.compare_digest(email.strip().lower(), USUARIO)
ok_senha = hmac.compare_digest(senha, SENHA)
return ok_email and ok_senha
@app.get("/login")
def login():
if autenticado():
return redirect(url_for("inicio"))
return render_template("login.html")
@app.post("/login")
def entrar():
email = request.form.get("email", "")
senha = request.form.get("senha", "")
if not credencial_confere(email, senha):
# Renderiza a própria tela com o erro, em vez de redirecionar: o
# redirect exigiria carregar a mensagem em estado global, e a app já
# tem uma sessão global só — duas abas se contaminariam.
# Mensagem única para e-mail errado e senha errada, de propósito.
return render_template("login.html", email=email,
erro="Usuário ou senha inválidos."), 401
session.clear()
session["autenticado"] = True
return redirect(url_for("inicio"))
@app.post("/sair")
def sair():
"""Encerra a sessão do navegador. Não mexe na SD em memória: quem entrar
depois continua de onde parou — é a mesma máquina, o mesmo processo."""
session.clear()
return redirect(url_for("login"))
def canonico(): def canonico():
return carregar_canonico() return carregar_canonico()
@@ -362,12 +450,18 @@ def revisar():
sess = ATUAL["sessao"] sess = ATUAL["sessao"]
if sess is None: if sess is None:
return redirect(url_for("inicio")) return redirect(url_for("inicio"))
# A prévia lista os entregáveis pelo `n`, e a adoção da tabela é por índice
# no frontmatter: traduz para a tela marcar a origem na linha certa.
ents = sess.dados.get("entregaveis") or []
da_tabela = {ents[i].get("n"): linha for i, linha in sess.linhas_da_tabela.items()
if i < len(ents) and isinstance(ents[i], dict)}
return render_template( return render_template(
"revisar.html", "revisar.html",
s=sess, s=sess,
rotulo=sess.rotulo_estado(), rotulo=sess.rotulo_estado(),
formulario=montar_formulario(sess), formulario=montar_formulario(sess),
previa=sess.previa or {}, previa=sess.previa or {},
linhas_da_tabela=da_tabela,
aviso=ATUAL.pop("aviso", None), aviso=ATUAL.pop("aviso", None),
) )
+42 -1
View File
@@ -26,7 +26,38 @@ from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts")) sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from exporta_sd import Pendencia, analisar # noqa: E402 from exporta_sd import Pendencia, analisar # noqa: E402
from regras_sd import Canonico # noqa: E402 from regras_sd import Canonico, linha_da_tabela # noqa: E402
def adotar_linhas_da_tabela(dados, canon: Canonico) -> dict[int, str]:
"""Escreve NO OBJETO DA SESSÃO a linha padrão da tabela onde o SD.md não
declara `linha_os`. Devolve {índice do entregável: linha} do que adotou.
Por que mutar aqui, e não deixar só o exportador consultar a tabela: o
Salvar SD.md regrava este objeto. Com a linha adotada nele, a SD sai do
disco autodeclarada e a tabela deixa de importar para ela — o risco de
tabela envelhecida morde uma vez só. O comentário de linha diz de onde
veio, para quem abrir o .md depois. O exportador faz a mesma consulta
(mesmo helper, mesma tabela), então a CLI sobre o .md original e a app dão
os mesmos bytes. Idempotente: só toca em quem não declara.
"""
tabela = canon.linhas_os
if not tabela:
return {}
adotadas: dict[int, str] = {}
for i, e in enumerate(dados.get("entregaveis") or []):
if not isinstance(e, dict) or e.get("linha_os") not in (None, ""):
continue
padrao, _ = linha_da_tabela(tabela, e.get("item"))
if not padrao:
continue
e["linha_os"] = padrao
if hasattr(e, "yaml_add_eol_comment"): # CommentedMap do ruamel
data = tabela.get("atualizado_em") or "sem data"
e.yaml_add_eol_comment(f"da tabela linhas-os.yaml ({data}) — o SD.md não declarava",
"linha_os")
adotadas[i] = padrao
return adotadas
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Estados — os que a tela precisa distinguir, e nenhum a mais # Estados — os que a tela precisa distinguir, e nenhum a mais
@@ -56,6 +87,10 @@ class Sessao:
caminho_origem: Path | None = None caminho_origem: Path | None = None
ultimo_erro: str | None = None ultimo_erro: str | None = None
exportada_em: str | None = None exportada_em: str | None = None
# {índice do entregável: linha} adotado de linhas-os.yaml porque o SD.md
# não declarava — a tela marca a origem, e o Salvar SD.md grava.
linhas_da_tabela: dict = field(default_factory=dict)
tabela_atualizada_em: str | None = None
def reavaliar(self, canon: Canonico) -> None: def reavaliar(self, canon: Canonico) -> None:
"""Roda o exportador em modo de análise sobre os dados COMO ESTÃO. """Roda o exportador em modo de análise sobre os dados COMO ESTÃO.
@@ -63,7 +98,13 @@ class Sessao:
É grátis e instantâneo; roda a cada edição. Uma edição depois da É grátis e instantâneo; roda a cada edição. Uma edição depois da
exportação volta a sessão para pronta/pendente: o que foi exportado era exportação volta a sessão para pronta/pendente: o que foi exportado era
outra versão dos dados, e a tela não pode dizer "exportada" sobre esta. outra versão dos dados, e a tela não pode dizer "exportada" sobre esta.
Antes de analisar, adota da tabela a linha de OS que o .md não declara
(ver adotar_linhas_da_tabela) — e só então o exportador vê os dados.
""" """
adotadas = adotar_linhas_da_tabela(self.dados, canon)
if adotadas:
self.linhas_da_tabela.update(adotadas)
self.tabela_atualizada_em = (canon.linhas_os or {}).get("atualizado_em")
self.previa, self.pendencias = analisar(self.dados, self.corpo, canon) self.previa, self.pendencias = analisar(self.dados, self.corpo, canon)
self.estado = PRONTA if not self.pendencias else COM_PENDENCIAS self.estado = PRONTA if not self.pendencias else COM_PENDENCIAS
self.exportada_em = None self.exportada_em = None
+17 -3
View File
@@ -129,6 +129,11 @@ header h1 {
} }
.cabecalho { display: flex; justify-content: space-between; align-items: center; .cabecalho { display: flex; justify-content: space-between; align-items: center;
gap: 1rem; margin-bottom: 0.75rem; } gap: 1rem; margin-bottom: 0.75rem; }
/* Tema e Sair juntos à direita do título; o form do Sair não pode empurrar
layout, por isso margem zero. */
.acoes-cabecalho { display: flex; align-items: center; gap: 0.3rem; flex: none; }
.acoes-cabecalho form { margin: 0; }
#alternar-tema { #alternar-tema {
font-size: 1.05rem; line-height: 1; padding: 0.35rem 0.65rem; flex: none; font-size: 1.05rem; line-height: 1; padding: 0.35rem 0.65rem; flex: none;
border-radius: 999px; border-radius: 999px;
@@ -239,16 +244,19 @@ ol.checklist li { margin-bottom: 0.2rem; }
label { display: block; margin-bottom: 0.7rem; font-size: 0.82rem; color: var(--fraco); font-weight: 500; } label { display: block; margin-bottom: 0.7rem; font-size: 0.82rem; color: var(--fraco); font-weight: 500; }
label.radio { display: inline-flex; align-items: center; gap: 0.35rem; margin-right: 1rem; label.radio { display: inline-flex; align-items: center; gap: 0.35rem; margin-right: 1rem;
color: var(--tinta); font-size: 0.82rem; font-weight: 400; } color: var(--tinta); font-size: 0.82rem; font-weight: 400; }
input[type=text], input:not([type]), select, textarea { input[type=text], input[type=email], input[type=password], input:not([type]),
select, textarea {
display: block; width: 100%; margin-top: 0.3rem; padding: 0.5rem 0.6rem; display: block; width: 100%; margin-top: 0.3rem; padding: 0.5rem 0.6rem;
border: 1px solid var(--linha); border-radius: 7px; font: inherit; border: 1px solid var(--linha); border-radius: 7px; font: inherit;
font-size: 0.88rem; color: var(--tinta); background: var(--papel); font-size: 0.88rem; color: var(--tinta); background: var(--papel);
transition: border-color 0.15s ease, box-shadow 0.15s ease; transition: border-color 0.15s ease, box-shadow 0.15s ease;
} }
input[type=text]:hover, input:not([type]):hover, select:hover, textarea:hover { input[type=text]:hover, input[type=email]:hover, input[type=password]:hover,
input:not([type]):hover, select:hover, textarea:hover {
border-color: var(--fraco); border-color: var(--fraco);
} }
input[type=text]:focus, input:not([type]):focus, select:focus, textarea:focus { input[type=text]:focus, input[type=email]:focus, input[type=password]:focus,
input:not([type]):focus, select:focus, textarea:focus {
border-color: var(--foco); border-color: var(--foco);
outline: 2px solid var(--foco); outline-offset: 1px; outline: 2px solid var(--foco); outline-offset: 1px;
} }
@@ -356,3 +364,9 @@ table.entregaveis th { font-size: 0.7rem; text-transform: uppercase; letter-spac
color: var(--fraco); font-weight: 600; } color: var(--fraco); font-weight: 600; }
table.entregaveis td.num { font-family: var(--fonte-mono); font-size: 0.78rem; table.entregaveis td.num { font-family: var(--fonte-mono); font-size: 0.78rem;
font-variant-numeric: tabular-nums; white-space: nowrap; } font-variant-numeric: tabular-nums; white-space: nowrap; }
/* Tela de entrada. Um cartão estreito e centralizado: a largura útil do resto
da app deixaria dois campos soltos no meio de uma faixa vazia. */
.cartao.entrada { max-width: 26rem; margin-left: auto; margin-right: auto; }
.cartao.entrada .acoes { margin-top: 1.1rem; }
.cartao.entrada button.primario { width: 100%; text-align: center; }
+9
View File
@@ -31,9 +31,17 @@
<header> <header>
<div class="cabecalho"> <div class="cabecalho">
<h1>Solicitação de Demanda — enviar · corrigir · exportar</h1> <h1>Solicitação de Demanda — enviar · corrigir · exportar</h1>
<div class="acoes-cabecalho">
<button type="button" id="alternar-tema" class="discreto" title="Alternar tema" <button type="button" id="alternar-tema" class="discreto" title="Alternar tema"
aria-label="Alternar tema claro e escuro">◐</button> aria-label="Alternar tema claro e escuro">◐</button>
{% if session.autenticado %}
<form method="post" action="{{ url_for('sair') }}">
<button type="submit" class="discreto" title="Encerrar a sessão">Sair</button>
</form>
{% endif %}
</div> </div>
</div>
{% block estados %}
<div class="estados"> <div class="estados">
{# Três etapas, porque são três as que existem: o arquivo chega, ou está {# Três etapas, porque são três as que existem: o arquivo chega, ou está
pronto ou tem pendência, e sai. "Com pendências" e "Pronta" ocupam a pronto ou tem pendência, e sai. "Com pendências" e "Pronta" ocupam a
@@ -46,6 +54,7 @@
{% endif %} {% endif %}
<span class="pastilha {{ 'atual' if estado_atual == 'exportada' }}">3 JSON exportado</span> <span class="pastilha {{ 'atual' if estado_atual == 'exportada' }}">3 JSON exportado</span>
</div> </div>
{% endblock %}
</header> </header>
<main id="topo"> <main id="topo">
+30
View File
@@ -0,0 +1,30 @@
{% extends "base.html" %}
{% block titulo %}Entrar{% endblock %}
{# A tela de login fica fora do fluxo enviar → corrigir → exportar: as três
etapas do cabeçalho não descrevem nada aqui, então o bloco esvazia. #}
{% block estados %}{% endblock %}
{% block conteudo %}
<section class="cartao entrada">
<h2>Entrar</h2>
<p>Acesso restrito. Informe o e-mail e a senha para usar a exportação da SD.</p>
<form method="post" action="{{ url_for('entrar') }}">
<label>E-mail
<input type="email" name="email" value="{{ email }}" placeholder="nome@dominio.com.br"
autocomplete="username" autofocus required>
</label>
<label>Senha
<input type="password" name="senha" autocomplete="current-password" required>
</label>
<div class="acoes">
<button type="submit" class="primario">Entrar</button>
</div>
</form>
<p class="nota">Credencial única, definida na configuração da aplicação. Não há
cadastro de usuário nem recuperação de senha — se a senha se perdeu, ela se
troca no ambiente de quem hospeda a app.</p>
</section>
{% endblock %}
+9 -1
View File
@@ -111,7 +111,7 @@
<td>{{ e.titulo }}</td> <td>{{ e.titulo }}</td>
<td>{{ e.tipo_entrega }}</td> <td>{{ e.tipo_entrega }}</td>
<td>{{ e.item }}</td> <td>{{ e.item }}</td>
<td class="{{ 'vermelho' if not e.ordem_servico.linha }}">{{ e.ordem_servico.linha or '—' }}</td> <td class="{{ 'vermelho' if not e.ordem_servico.linha }}">{{ e.ordem_servico.linha or '—' }}{% if e.n in linhas_da_tabela %} <span class="selo" title="o SD.md não declarava linha_os; veio de contrato/linhas-os.yaml">tabela</span>{% endif %}</td>
<td class="num">{{ e.data_inicio or '—' }}</td> <td class="num">{{ e.data_inicio or '—' }}</td>
<td class="num">{{ e.data_prevista_termino or '—' }}</td> <td class="num">{{ e.data_prevista_termino or '—' }}</td>
<td class="num">{% if e.data_inicio and e.data_prevista_termino %}{{ dias(e.data_inicio, e.data_prevista_termino) }}{% else %}—{% endif %}</td> <td class="num">{% if e.data_inicio and e.data_prevista_termino %}{{ dias(e.data_inicio, e.data_prevista_termino) }}{% else %}—{% endif %}</td>
@@ -126,6 +126,14 @@
valor <strong>não saem daqui</strong>: o sistema os calcula na carga a partir de datas, item valor <strong>não saem daqui</strong>: o sistema os calcula na carga a partir de datas, item
e tipo, com as tabelas vigentes. Linha de OS é o <code>linha_os</code> declarado no SD.md; e tipo, com as tabelas vigentes. Linha de OS é o <code>linha_os</code> declarado no SD.md;
quem confere se a OS e a linha existem é o sistema de gestão, na carga.</p> quem confere se a OS e a linha existem é o sistema de gestão, na carga.</p>
{% if linhas_da_tabela %}
<p class="nota"><span class="selo">tabela</span> {{ linhas_da_tabela|length }} entregável(is) não
declaravam <code>linha_os</code> no SD.md: a linha veio de <code>contrato/linhas-os.yaml</code>
(atualizada em {{ s.tabela_atualizada_em or 'data não informada' }}), pelo padrão do item.
<strong>Salvar SD.md…</strong> grava essa linha no arquivo, com a origem em comentário —
a SD passa a declará-la e a tabela deixa de importar para ela. Se a OS certa for outra,
corrija no SD.md.</p>
{% endif %}
</section> </section>
{% endblock %} {% endblock %}
+53 -22
View File
@@ -1,46 +1,77 @@
# Deploy em VPS pelo painel Coolify (recurso do tipo "Docker Compose"). # Deploy da Exportação da SD — a imagem é a do Dockerfile; este arquivo é UM dos
# dois jeitos de subi-la no Coolify, e serve também para rodar local.
# #
# ANTES do primeiro deploy, ponha os dados do cliente em um caminho FIXO do # ── MODO 1 · Application (Dockerfile) — o que está em produção (2026-09-04) ──
# host, FORA da pasta que o Coolify clona — ele re-clona a cada deploy, e
# clientes/ está no .gitignore, então o clone nunca traz esses dados:
# #
# scp -r clientes/ usuario@vps:/dados/exporta-sd/clientes # Neste modo o Coolify IGNORA este arquivo por completo: builda o Dockerfile e
# configura tudo pela interface. O que o volume e as variáveis daqui dizem tem
# de ser reproduzido lá:
# #
# Não precisa de chown manual: o entrypoint.sh da imagem roda como root no # Persistent storage → Add mount → "Directory mount"
# início do container e ajusta o dono do volume para o usuário `sd` antes de # Source Path: /data/coolify/applications/<uuid-da-app>/clientes
# derrubar privilégio e subir o gunicorn — ver entrypoint.sh e o ENTRYPOINT # (o próprio Coolify sugere esse caminho; ele sobrevive
# no Dockerfile. (Uma reconsequência: cada `scp` novo devolve os arquivos ao # a redeploy — é a pasta de storage da app, não o clone)
# usuário do ssh no host; some sozinho no próximo restart do container.) # Destination Path: /app/clientes
# Depois, no Coolify → Environment Variables, aponte o volume para lá: # Mesmo esquema para _derivados, só se for usar a linha de comando.
# #
# CLIENTES_DIR=/dados/exporta-sd/clientes # Dados: a pasta do host nasce VAZIA. Copie o conteúdo para dentro dela —
# DERIVADOS_DIR=/dados/exporta-sd/_derivados # só se usar a linha de comando # aponte para "ses-mg", não para "clientes", ou o nível duplica
# (.../clientes/clientes/ses-mg):
# #
# Sem essas variáveis, o padrão ./clientes cai dentro da pasta do clone, o # scp -r "D:/Exporta-SD/clientes/ses-mg" usuario@vps:/data/coolify/applications/<uuid>/clientes/
# compose cria o diretório VAZIO sem avisar e o primeiro upload morre com
# "Arquivo canônico ausente".
# #
# Resto do painel: porta exposta 5000, réplicas 1 (ver o comentário de -w 1 no # e confira no host: ls /data/coolify/applications/<uuid>/clientes/ses-mg/contrato/
# Dockerfile), e o domínio o Coolify publica via Traefik. # Tem de listar itens.yaml E linhas-os.yaml — sem o segundo, o fallback da
# linha de OS desliga e `linha_os` vira pendência só no VPS.
#
# Variáveis: aba Environment Variables (nomes e defaults em .env.example).
# Réplicas: 1 (ver o comentário de -w 1 no Dockerfile).
#
# ── MODO 2 · Docker Compose (ou local) ──────────────────────────────────────
#
# O Coolify lê este arquivo. Ponha os dados num caminho fixo do host, fora da
# pasta que ele clona, e aponte CLIENTES_DIR para lá (Environment Variables no
# Coolify, ou um .env ao lado deste arquivo — copie .env.example). Sem a
# variável, o padrão ./clientes cai dentro do clone, o compose cria o
# diretório VAZIO sem avisar e o primeiro upload morre com "Arquivo canônico
# ausente".
#
# Nos dois modos não há chown manual: o entrypoint.sh roda como root ao subir
# o container, ajusta o dono do volume para o usuário `sd` e só então derruba
# privilégio e inicia o gunicorn. Um scp novo devolve os arquivos ao usuário do
# ssh; resolve sozinho no próximo restart.
services: services:
exporta-sd: exporta-sd:
build: build:
context: . context: .
dockerfile: Dockerfile dockerfile: Dockerfile
image: exporta-sd:local
# Sem `ports:` de propósito: no Coolify o Traefik alcança o container pela # Sem `ports:` de propósito: no Coolify o Traefik alcança o container pela
# rede interna. `expose` publica a porta só para dentro dessa rede. # rede interna. `expose` publica a porta só para dentro dessa rede.
# Para rodar fora do Coolify, troque por: ports: ["5000:5000"]. # Para rodar fora do Coolify, troque por: ports: ["5000:5000"].
expose: expose:
- "5000" - "5000"
# Só ESPELHA os defaults do Dockerfile — o valor de verdade mora lá (ENV e
# ${VAR:-default} no CMD), porque no Modo 1 este bloco nem é lido. Aqui a
# única função é deixar o operador trocar pelo .env sem editar YAML.
environment: environment:
PORT: "5000" PORT: ${PORT:-5000}
TZ: ${TZ:-America/Sao_Paulo}
GUNICORN_TIMEOUT: ${GUNICORN_TIMEOUT:-120}
GUNICORN_LOG_LEVEL: ${GUNICORN_LOG_LEVEL:-info}
# Credencial única da tela de login (não há banco nem cadastro).
APP_USUARIO: ${APP_USUARIO:-admin@iasis.com.br}
APP_SENHA: ${APP_SENHA:-admin123}
# Vazio de propósito: sem valor, o servidor sorteia a chave por
# processo e o login cai a cada restart. Defina no .env para durar.
SECRET_KEY: ${SECRET_KEY:-}
volumes: volumes:
# OBRIGATÓRIO. O app lê clientes/ses-mg/contrato/itens.yaml a cada # OBRIGATÓRIO. O app lê clientes/ses-mg/contrato/itens.yaml a cada
# análise e grava SD.md em clientes/ses-mg/projetos/. O caminho no # análise (e contrato/linhas-os.yaml, se existir, como fallback da linha
# de OS) e grava SD.md em clientes/ses-mg/projetos/. O caminho no
# container é fixo: caminhos.py resolve clientes/ a partir da raiz do # container é fixo: caminhos.py resolve clientes/ a partir da raiz do
# código, que é o WORKDIR /app. Sem esta montagem, a tela inicial abre # código, que é o WORKDIR /app.
# e o primeiro upload falha com "Arquivo canônico ausente".
- ${CLIENTES_DIR:-./clientes}:/app/clientes - ${CLIENTES_DIR:-./clientes}:/app/clientes
# Saída da linha de comando (exporta_sd.py --dir _derivados/export). # Saída da linha de comando (exporta_sd.py --dir _derivados/export).
# A aplicação web entrega o JSON pelo download, não por aqui. # A aplicação web entrega o JSON pelo download, não por aqui.
+3
View File
@@ -21,6 +21,9 @@ METODOS = RAIZ / "metodos"
DERIVADOS = RAIZ / "_derivados" DERIVADOS = RAIZ / "_derivados"
ITENS = CONTRATO / "itens.yaml" ITENS = CONTRATO / "itens.yaml"
# Fallback de linha de OS (OS Mãe × linha × item). OPCIONAL: ausente, o
# exportador exige `linha_os` declarado no SD.md, como antes de 2026-09-04.
LINHAS_OS = CONTRATO / "linhas-os.yaml"
# Visões GERADAS a partir das SDs (inversão da V0, 13/08/2026). Quem lê # Visões GERADAS a partir das SDs (inversão da V0, 13/08/2026). Quem lê
# portfólio, carteira ou catálogo lê o resultado de `make derivar` — os # portfólio, carteira ou catálogo lê o resultado de `make derivar` — os
+37 -12
View File
@@ -28,12 +28,15 @@ reajuste. As chaves continuam no JSON, na mesma posição — só o valor é `nu
`horas_semanais` e `memoria_calculo`, que só existiam para explicar a conta, `horas_semanais` e `memoria_calculo`, que só existiam para explicar a conta,
saem `null` pelo mesmo motivo. saem `null` pelo mesmo motivo.
O que NÃO é derivado, por decisão da gestão (2026-09-03): `ordem_servico.linha`. O que NÃO é derivado: `ordem_servico.linha`. OS e linha são cadastro do sistema
OS e linha são cadastro do sistema de gestão e mudam durante o ano; um de-para de gestão e mudam durante o ano. Quem emite a SD declara `linha_os` no
local envelheceria em silêncio. Quem emite a SD declara `linha_os` no entregável, copiado da tela da OS; aqui só o formato `{OS}-L{n}` é conferido, e
entregável, copiado da tela da OS; aqui só o formato `{OS}-L{n}` é conferido, existência, item e status quem confere é o banco, na carga — que recusa
e ausente é pendência. Existência, item e status quem confere é o banco, na nomeando o que falta. AUSENTE, a tabela contrato/linhas-os.yaml (se existir)
carga — que recusa nomeando o que falta. dá a linha padrão do item — fallback, decidido em 2026-09-04, para poupar a
digitação; sem padrão é pendência, com as candidatas nomeadas quando a tabela
tem mais de uma. A linha declarada nunca é conferida contra a tabela (decisão
de 2026-09-03): o .md manda.
O que este script NÃO faz, por decisão da gestão (2026-09-03): não confere os O que este script NÃO faz, por decisão da gestão (2026-09-03): não confere os
tetos de texto da §2/§3/§4 (nome ≤ 160, objetivo ≤ 4000 etc.) — o importador tetos de texto da §2/§3/§4 (nome ≤ 160, objetivo ≤ 4000 etc.) — o importador
@@ -85,6 +88,7 @@ from regras_sd import (
enquadramento_tr, enquadramento_tr,
janela_dias, janela_dias,
ler_sd_arquivo, ler_sd_arquivo,
linha_da_tabela,
perfil_do_cadastro, perfil_do_cadastro,
secao, secao,
semanas_por_datas, semanas_por_datas,
@@ -421,15 +425,31 @@ def _montar(sd: dict, corpo: str, canon: Canonico, pend: list[Pendencia]) -> dic
"na carga (time-box × semanas das datas). Apague a declaração.") "na carga (time-box × semanas das datas). Apague a declaração.")
# -- linha de OS ----------------------------------------------------------- # -- linha de OS -----------------------------------------------------------
# Declarada no SD.md por quem emite a SD, e só o formato é conferido # Declarada no SD.md: vai como está, só o formato é conferido — OS e
# aqui. OS e linha são cadastro do sistema de gestão e mudam durante o # linha são cadastro do sistema de gestão, e quem sabe se existem é o
# ano; quem sabe se existem é o banco, na carga. Não há de-para local. # banco, na carga. Ausente, a tabela contrato/linhas-os.yaml (se houver)
# dá a linha padrão do item; sem padrão é pendência, e com mais de uma
# candidata a mensagem as nomeia para a pessoa escolher.
linha = None linha = None
queixa = conferir_formato_linha_os(e.get("linha_os")) declarada = e.get("linha_os")
if declarada in (None, ""):
padrao, candidatas = linha_da_tabela(canon.linhas_os, item) if item_ok else (None, [])
if padrao:
linha = padrao
elif len(candidatas) > 1:
falta("linha_os", f"`linha_os` ausente, e a tabela linhas-os.yaml tem {len(candidatas)} "
f"linhas para {item} sem `padrao` marcado: {', '.join(candidatas)}. "
"Escolha uma aqui, ou marque o padrão na tabela", opcoes=candidatas)
else:
sem_entrada = f" — a tabela linhas-os.yaml não tem linha para {item}" \
if canon.linhas_os and item_ok else ""
falta("linha_os", conferir_formato_linha_os(None) + sem_entrada)
else:
queixa = conferir_formato_linha_os(declarada)
if queixa: if queixa:
falta("linha_os", queixa) falta("linha_os", queixa)
else: else:
linha = str(e.get("linha_os")).strip() linha = str(declarada).strip()
# -- listas filhas ----------------------------------------------------------- # -- listas filhas -----------------------------------------------------------
for campo in ("atividades", "criterios_aceite"): for campo in ("atividades", "criterios_aceite"):
@@ -695,8 +715,13 @@ def main() -> int:
print(f" ERRO {f}: {exc}", file=sys.stderr) print(f" ERRO {f}: {exc}", file=sys.stderr)
falhas.append(f) falhas.append(f)
continue continue
# Se montar() passou com `linha_os` ausente, a linha veio da tabela —
# dizer quantas: é a única pista de que o .md ainda não a declara.
da_tabela = sum(1 for e in (sd.get("entregaveis") or [])
if isinstance(e, dict) and e.get("linha_os") in (None, ""))
print(f" {rotulo} → {saida} {len(payload['entregaveis'])} entregável(is) " print(f" {rotulo} → {saida} {len(payload['entregaveis'])} entregável(is) "
f"({', '.join(payload['sd']['itens_contratuais'])})") f"({', '.join(payload['sd']['itens_contratuais'])})"
+ (f" · {da_tabela} linha(s) de OS da tabela linhas-os.yaml" if da_tabela else ""))
if falhas: if falhas:
# Isolar sem nomear é pior que o estouro que substitui: o lote # Isolar sem nomear é pior que o estouro que substitui: o lote
+94 -12
View File
@@ -30,6 +30,7 @@ except ImportError:
sys.exit("Falta pyyaml. pip install pyyaml") sys.exit("Falta pyyaml. pip install pyyaml")
from caminhos import ITENS as ITENS_YAML from caminhos import ITENS as ITENS_YAML
from caminhos import LINHAS_OS as LINHAS_OS_YAML
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -40,23 +41,44 @@ class Canonico(NamedTuple):
"""O YAML de contrato, carregado uma vez. """O YAML de contrato, carregado uma vez.
`itens` dá a lista de itens do contrato e a matriz de perfis do TR `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 4.1.1.7.1, que alimenta `alocacoes`. Time-box e tarifa saíram daqui em
exportador depende. Time-box e tarifa saíram daqui em 2026-09-03: são 2026-09-03: são tabelas do sistema de gestão, e é ele que deriva UST e
tabelas do sistema de gestão, e é ele que deriva UST e valor na carga. valor na carga.
O que NÃO está aqui, de propósito: OS e linhas de OS. Elas são cadastro do `linhas_os` é a tabela OS Mãe × linha × item (contrato/linhas-os.yaml), já
sistema de gestão e mudam durante o ano; qualquer cópia local envelhece em preparada por preparar_linhas_os() — ou None quando o arquivo não existe.
silêncio. O exportador exige `linha_os` declarado no SD.md e só confere o É FALLBACK, não fonte: só entra quando o entregável NÃO declara `linha_os`
formato — existência, item e status quem confere é o banco, na carga. 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 itens: dict
linhas_os: dict | None = None
def carregar_canonico() -> Canonico: def carregar_canonico() -> Canonico:
if not ITENS_YAML.exists(): if not ITENS_YAML.exists():
raise FileNotFoundError(f"Arquivo canônico ausente: {ITENS_YAML}") raise FileNotFoundError(f"Arquivo canônico ausente: {ITENS_YAML}")
itens = yaml.safe_load(ITENS_YAML.read_text(encoding="utf-8")) itens = yaml.safe_load(ITENS_YAML.read_text(encoding="utf-8"))
return Canonico(itens) 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
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -500,10 +522,10 @@ def conferir_formato_linha_os(linha) -> str | None:
Só FORMATO. Existência da OS, da linha, item e status são cadastro do 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 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 no banco, §5) — a carga recusa nomeando o que falta e nada é gravado. A
há cópia local disso aqui de propósito: OS e linhas novas entram durante o tabela linhas-os.yaml NÃO é conferida contra o valor declarado: ela é
ano, e um de-para em arquivo apontaria linha velha sem ninguém notar. fallback para a ausência (ver linha_da_tabela), não régua do que a pessoa
Decisão da gestão em 2026-09-03. copiou da tela da OS. Decisões da gestão em 2026-09-03 e 2026-09-04.
""" """
if linha in (None, ""): if linha in (None, ""):
return ("`linha_os` ausente — ordem_servico.linha é obrigatório na carga e só quem " return ("`linha_os` ausente — ordem_servico.linha é obrigatório na carga e só quem "
@@ -514,6 +536,66 @@ def conferir_formato_linha_os(linha) -> str | None:
return None 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: def janela_dias(inicio: date, fim: date) -> int:
"""Dias entre as datas, exclusivo — a mesma subtração que a carga faz.""" """Dias entre as datas, exclusivo — a mesma subtração que a carga faz."""
return (fim - inicio).days return (fim - inicio).days
+39 -41
View File
@@ -43,6 +43,13 @@ import servidor # noqa: E402
FALHAS: list[str] = [] FALHAS: list[str] = []
CLIENTE = servidor.app.test_client() CLIENTE = servidor.app.test_client()
# O portão de login vale para todas as rotas (servidor.exigir_login); sem esta
# entrada, todo request abaixo voltaria 302 para /login e nenhum teste chegaria
# ao handler que quer exercitar.
CLIENTE.post("/login", data={"email": servidor.USUARIO, "senha": servidor.SENHA})
# Lida da tabela, não fixada: o cabeçalho de linhas-os.yaml manda atualizar
# `atualizado_em` a cada edição, e um teste pinado ficaria vermelho à toa.
DATA_TABELA = servidor.canonico().linhas_os["atualizado_em"]
SD8 = PROJETOS / "P2-vacinacao/sds/SD8-mvp-vacina-em-dia/SD.md" SD8 = PROJETOS / "P2-vacinacao/sds/SD8-mvp-vacina-em-dia/SD.md"
SD10 = PROJETOS / "P2-vacinacao/sds/SD10-crie/SD.md" SD10 = PROJETOS / "P2-vacinacao/sds/SD10-crie/SD.md"
@@ -119,56 +126,44 @@ def exportar_pela_cli(sd_md: Path) -> str:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def preencher_linhas(pag: str, linha: str = "1090-L1") -> str:
"""Responde no formulário todas as pendências de `linha_os` com a mesma
linha — o que a pessoa faria copiando da tela da OS. Devolve a página."""
c = campos_do_form(pag)
for k in c:
if k.endswith("__linha_os"):
c[k] = linha
return texto(post("/revisar", c))
def sd_pronta(): def sd_pronta():
print("\n1. SD31: só falta a linha de OS; preenchida, o clique exporta — e nada sai antes dele") print("\n1. SD31: o .md não declara linha de OS; a tabela preenche, o clique exporta — e nada sai antes dele")
reiniciar() reiniciar()
r = enviar(SD31) r = enviar(SD31)
ck(not baixou(r), "o upload NÃO devolve download — devolve a tela de revisão") ck(not baixou(r), "o upload NÃO devolve download — devolve a tela de revisão")
pag = texto(r) pag = texto(r)
ck(estado_na_tela(pag) == "Com pendências" and "4 pendência(s)" in pag, ck(estado_na_tela(pag) == "Pronta para exportar",
"estado: com pendências — as 4 linhas de OS que o SD.md não declara") "estado: pronta — as 4 linhas que o SD.md não declara vieram de contrato/linhas-os.yaml")
c = campos_do_form(pag)
ck(set(c) == {f"e{i}__linha_os" for i in range(4)},
f"o formulário traz SÓ os quatro campos de linha de OS, como texto ({sorted(c)})")
ck("<select" not in pag, "sem select de candidatas: não há de-para local, a pessoa copia da tela da OS")
ck(not baixou(post("/exportar")), "POST /exportar direto não baixa nada enquanto falta a linha")
pag = preencher_linhas(pag)
ck(estado_na_tela(pag) == "Pronta para exportar", "preenchidas, estado: pronta para exportar")
ck("PENDENTE" not in pag and 'id="form-pendencias"' not in pag, ck("PENDENTE" not in pag and 'id="form-pendencias"' not in pag,
"sem pendência não há formulário nenhum na tela") "sem pendência não há formulário nenhum na tela")
ck("1090-L1" in pag, "a linha de OS declarada aparece no resumo") ck(pag.count(">tabela</span>") >= 4 and "linhas-os.yaml" in pag and DATA_TABELA in pag,
"a tela marca a origem em cada linha e diz a data da tabela")
ck("1090-L1" in pag, "a linha padrão de I-02 aparece no resumo")
ck("Exportar JSON" in pag and "disabled" not in pag.split("Exportar JSON")[0][-200:], ck("Exportar JSON" in pag and "disabled" not in pag.split("Exportar JSON")[0][-200:],
"o botão Exportar está habilitado") "o botão Exportar está habilitado")
ck(not baixou(CLIENTE.get("/revisar")), "GET /revisar não baixa nada — só o /exportar baixa")
r = post("/exportar") r = post("/exportar")
ck(baixou(r) and "P7-SD31.json" in r.headers.get("Content-Disposition", ""), "download entregue") ck(baixou(r) and "P7-SD31.json" in r.headers.get("Content-Disposition", ""), "download entregue")
baixado = texto(r) baixado = texto(r)
p = json.loads(baixado) p = json.loads(baixado)
ck(p["sd"]["codigo"] == "P7·SD31", "é o JSON da SD certa") ck(p["sd"]["codigo"] == "P7·SD31", "é o JSON da SD certa")
ck(all(e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]), ck(all(e["item"] == "I-02" and e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]),
"todo entregável saiu com a linha de OS preenchida") "todo entregável (I-02) saiu com a linha padrão do item na tabela")
ck(p["sd"]["status"] == "Planejado" and all(e["status"] == "Rascunho" for e in p["entregaveis"]), ck(p["sd"]["status"] == "Planejado" and all(e["status"] == "Rascunho" for e in p["entregaveis"]),
"status nos rótulos do contrato") "status nos rótulos do contrato")
ck(estado_na_tela(pagina()) == "JSON exportado", "a tela registra a exportação") ck(estado_na_tela(pagina()) == "JSON exportado", "a tela registra a exportação")
# A mesma régua da CLI: grava o SD.md corrigido (Salvar SD.md) e exporta # A mesma régua da CLI, dos dois lados: sobre o SD.md ORIGINAL (a CLI
# esse arquivo pela linha de comando — tem de dar os mesmos bytes. # consulta a mesma tabela) e sobre o SD.md gravado pelo Salvar SD.md (que
# agora declara as linhas, com a origem em comentário).
ck(baixado == exportar_pela_cli(SD31), "bytes idênticos aos do exporta_sd.py sobre o SD.md original")
with tempfile.TemporaryDirectory() as tmp: with tempfile.TemporaryDirectory() as tmp:
alvo = Path(tmp) / "SD.md" alvo = Path(tmp) / "SD.md"
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"}) post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
ck(alvo.exists() and alvo.read_text(encoding="utf-8").count("linha_os: 1090-L1") == 4, txt = alvo.read_text(encoding="utf-8") if alvo.exists() else ""
"o SD.md gravado leva as quatro linhas declaradas") ck(txt.count("linha_os: 1090-L1") == 4 and txt.count(f"# da tabela linhas-os.yaml ({DATA_TABELA})") == 4,
"o SD.md gravado declara as quatro linhas, cada uma com a origem em comentário")
ck(baixado == exportar_pela_cli(alvo), "bytes idênticos aos do exporta_sd.py sobre o SD.md gravado") ck(baixado == exportar_pela_cli(alvo), "bytes idênticos aos do exporta_sd.py sobre o SD.md gravado")
@@ -237,13 +232,11 @@ def po_e_objetivo():
reiniciar() reiniciar()
pag = texto(enviar(SD10)) pag = texto(enviar(SD10))
c = campos_do_form(pag) c = campos_do_form(pag)
ck(set(c) == {"sd__po_responsavel", "corpo__objetivo"} | {f"e{i}__linha_os" for i in range(5)}, ck(set(c) == {"sd__po_responsavel", "corpo__objetivo"},
f"PO, objetivo e as cinco linhas de OS no formulário — nada mais ({sorted(c)})") f"PO e objetivo no formulário — nada mais: as cinco linhas de OS vieram da tabela ({sorted(c)})")
ck("[a designar]" not in pag and "[a redigir" not in pag, "nenhum placeholder inventado na tela") ck("[a designar]" not in pag and "[a redigir" not in pag, "nenhum placeholder inventado na tela")
c["sd__po_responsavel"] = "Guilherme Bernardino" c["sd__po_responsavel"] = "Guilherme Bernardino"
c["corpo__objetivo"] = "Permitir à equipe do CRIE manter as regras de elegibilidade sem deploy." c["corpo__objetivo"] = "Permitir à equipe do CRIE manter as regras de elegibilidade sem deploy."
for i in range(5):
c[f"e{i}__linha_os"] = "1090-L1" if i == 2 else "2587-L3" # n3 é I-02; os demais, I-05
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar", "preenchidos, a SD fica pronta") ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar", "preenchidos, a SD fica pronta")
p = json.loads(texto(post("/exportar"))) p = json.loads(texto(post("/exportar")))
ck(p["sd"]["po_responsavel"]["nome"] == "Guilherme Bernardino", "o PO chega ao JSON") ck(p["sd"]["po_responsavel"]["nome"] == "Guilherme Bernardino", "o PO chega ao JSON")
@@ -253,7 +246,7 @@ def po_e_objetivo():
"a Seção 1 foi criada no corpo, antes da Seção 5") "a Seção 1 foi criada no corpo, antes da Seção 5")
ck([e["ordem_servico"]["linha"] for e in p["entregaveis"]] ck([e["ordem_servico"]["linha"] for e in p["entregaveis"]]
== ["2587-L3", "2587-L3", "1090-L1", "2587-L3", "2587-L3"], == ["2587-L3", "2587-L3", "1090-L1", "2587-L3", "2587-L3"],
"cada entregável saiu com a linha que a pessoa declarou") "cada entregável saiu com o padrão do SEU item na tabela (n3 é I-02; os demais, I-05)")
def sem_datas(): def sem_datas():
@@ -330,7 +323,7 @@ def writeback():
def linha_de_os_no_formulario(): def linha_de_os_no_formulario():
print("\n9. Linha de OS: só o formato é conferido aqui; existência é do banco, na carga") print("\n9. Linha de OS: declarada, só o formato é conferido; apagada, a tabela preenche o padrão do item")
sd_md = ("---\n" sd_md = ("---\n"
'sd: "P9·SD99"\nprojeto: "P9 · Teste"\ntitulo: "Sintética"\n' 'sd: "P9·SD99"\nprojeto: "P9 · Teste"\ntitulo: "Sintética"\n'
'po_responsavel: "Alguém"\nversao: "V1"\nestado: rascunho\n' 'po_responsavel: "Alguém"\nversao: "V1"\nestado: rascunho\n'
@@ -341,28 +334,33 @@ def linha_de_os_no_formulario():
reiniciar() reiniciar()
pag = texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md)) pag = texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md))
ck("fora do formato" in pag and "1090-L1" in pag, "linha mal formada: pendência que mostra o formato") ck("fora do formato" in pag and "1090-L1" in pag, "linha mal formada: pendência que mostra o formato")
ck('<input name="e0__linha_os"' in pag and "<select" not in pag, "campo de texto, sem lista local") ck('<input name="e0__linha_os"' in pag and "<select" not in pag,
"campo de texto: a pessoa corrige o que declarou, a tabela não se mete no declarado")
ck(">tabela</span>" not in pag, "linha declarada (mesmo errada) não é trocada pela da tabela")
c = campos_do_form(pag) c = campos_do_form(pag)
c["e0__linha_os"] = "9999-L7" c["e0__linha_os"] = "9999-L7"
ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar", ck(estado_na_tela(texto(post("/revisar", c))) == "Pronta para exportar",
"linha bem formada que o repositório não conhece PASSA — quem confere existência é o banco") "linha bem formada que a tabela não conhece PASSA — quem confere existência é o banco")
p = json.loads(texto(post("/exportar"))) p = json.loads(texto(post("/exportar")))
ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "9999-L7", "o que a pessoa declarou vai no JSON") ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "9999-L7", "o que a pessoa declarou vai no JSON")
# Apagar a declaração é "não sei": a tabela entra e dá o padrão do item.
reiniciar() reiniciar()
c = campos_do_form(texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md))) c = campos_do_form(texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md)))
c["e0__linha_os"] = "" c["e0__linha_os"] = ""
pag = texto(post("/revisar", c)) pag = texto(post("/revisar", c))
ck("`linha_os` ausente" in pag and not baixou(post("/exportar")), ck(estado_na_tela(pag) == "Pronta para exportar" and ">tabela</span>" in pag,
"em branco não há fallback nenhum: continua pendente e não exporta") "em branco, a tabela preenche o padrão de I-02 e a tela marca a origem")
ck("linha_os" not in servidor.ATUAL["sessao"].dados["entregaveis"][0], e0 = servidor.ATUAL["sessao"].dados["entregaveis"][0]
"e a chave `linha_os` saiu do frontmatter, em vez de virar string vazia") ck(e0.get("linha_os") == "1090-L1", "a linha da tabela foi escrita no frontmatter da sessão (para o Salvar SD.md)")
p = json.loads(texto(post("/exportar")))
ck(p["entregaveis"][0]["ordem_servico"]["linha"] == "1090-L1", "e é ela que vai no JSON")
def editar_depois_de_exportar(): def editar_depois_de_exportar():
print("\n10. Editar depois de exportar desfaz o rótulo 'exportada'") print("\n10. Editar depois de exportar desfaz o rótulo 'exportada'")
reiniciar() reiniciar()
preencher_linhas(texto(enviar(SD31))) enviar(SD31) # pronta de saída: as linhas de OS vêm da tabela
post("/exportar") post("/exportar")
ck(estado_na_tela(pagina()) == "JSON exportado", "exportada") ck(estado_na_tela(pagina()) == "JSON exportado", "exportada")
post("/revisar", {"sd__titulo": "outro título"}) post("/revisar", {"sd__titulo": "outro título"})
+93 -48
View File
@@ -9,8 +9,9 @@ O que está fixado aqui, na ordem em que custa mais errar:
que o importador já consome (scripts/backlog/sd/P7-SD31.json). A rotina que o importador já consome (scripts/backlog/sd/P7-SD31.json). A rotina
de importação depende disso; só VALORES podem mudar. de importação depende disso; só VALORES podem mudar.
2. CONTRATO — cada SD que exporta cumpre CONTRATO-JSON-V2.md: linha de OS 2. CONTRATO — cada SD que exporta cumpre CONTRATO-JSON-V2.md: linha de OS
preenchida, UST = time-box × semanas(datas), valor = UST × tarifa, janela preenchida (declarada no .md ou, ausente, o padrão do item em
≤ 28 dias, vocabulário exato da §6, unicidades da §8. contrato/linhas-os.yaml), UST/tarifa/valor null para o importador derivar,
janela ≤ 28 dias, vocabulário exato da §6, unicidades da §8.
3. PENDÊNCIAS — as SDs que NÃO exportam ficam pendentes pelos motivos certos, 3. PENDÊNCIAS — as SDs que NÃO exportam ficam pendentes pelos motivos certos,
campo a campo, e nenhuma outra. campo a campo, e nenhuma outra.
4. MESMA RÉGUA — o caminho da app (ruamel) e o da CLI (safe_load) produzem 4. MESMA RÉGUA — o caminho da app (ruamel) e o da CLI (safe_load) produzem
@@ -33,8 +34,9 @@ from datetime import date
from caminhos import PROJETOS, RAIZ, sds_reais from caminhos import PROJETOS, RAIZ, sds_reais
from exporta_sd import ExportacaoReprovada, analisar, montar, serializar from exporta_sd import ExportacaoReprovada, analisar, montar, serializar
from regras_sd import (JANELA_MAXIMA_DIAS, PERFIS_DO_CADASTRO, STATUS_ENTREGAVEL, STATUS_SD, from regras_sd import (JANELA_MAXIMA_DIAS, PERFIS_DO_CADASTRO, STATUS_ENTREGAVEL, STATUS_SD,
carregar_canonico, definir_secao, enquadramento_tr, ler_sd_arquivo, Canonico, carregar_canonico, definir_secao, enquadramento_tr,
ler_sd_texto, secao, semanas_por_datas) ler_sd_arquivo, ler_sd_texto, linha_da_tabela, preparar_linhas_os,
secao, semanas_por_datas)
FALHAS: list[str] = [] FALHAS: list[str] = []
@@ -42,42 +44,28 @@ FALHAS: list[str] = []
# pendências esperadas como (escopo, indice, campo) — lista vazia = exporta. # pendências esperadas como (escopo, indice, campo) — lista vazia = exporta.
# Não é silenciamento: a suíte segue vermelha para qualquer pendência NOVA ou # Não é silenciamento: a suíte segue vermelha para qualquer pendência NOVA ou
# para uma esperada que sumir sem o SD.md ter mudado. # para uma esperada que sumir sem o SD.md ter mudado.
def _sem_linha(n: int) -> list[tuple]: #
"""SD cujos n entregáveis ainda não declaram `linha_os` no SD.md.""" # Desde 2026-09-04 a falta de `linha_os` no .md deixou de ser pendência para os
return [("entregavel", i, "linha_os") for i in range(n)] # itens que contrato/linhas-os.yaml conhece: SD18, SD27 e SD31–SD36 (que só
# tinham isso) passaram a exportar, e SD10 ficou só com PO e objetivo. O que a
# tabela preencheu é conferido em acervo(), linha a linha.
ESPERADO = { ESPERADO = {
"SD8-mvp-vacina-em-dia": [("entregavel", 1, "semanas"), ("entregavel", 2, "semanas"), "SD8-mvp-vacina-em-dia": [("entregavel", 1, "semanas"), ("entregavel", 2, "semanas"),
("entregavel", 7, "fim")], ("entregavel", 7, "fim")],
"SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")] + _sem_linha(5), "SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")],
"SD18-integracao-core-rnds-samu": _sem_linha(4), "SD18-integracao-core-rnds-samu": [],
"SD20-siscan-d1": [("sd", None, "po_responsavel")] "SD20-siscan-d1": [("sd", None, "po_responsavel")]
+ [("entregavel", i, c) for i in range(5) for c in ("inicio", "fim")], + [("entregavel", i, c) for i in range(5) for c in ("inicio", "fim")],
"SD24-mvp-institucional": [("entregavel", 3, "semanas")], "SD24-mvp-institucional": [("entregavel", 3, "semanas")],
"SD27-design-thinking-oficina-superintendencias": _sem_linha(3), "SD27-design-thinking-oficina-superintendencias": [],
"SD31-fundacao-da-base-de-dispensacao": _sem_linha(4), "SD31-fundacao-da-base-de-dispensacao": [],
"SD32-extracao-automatizada-d-1-do-sigaf": _sem_linha(4), "SD32-extracao-automatizada-d-1-do-sigaf": [],
"SD33-painel-institucional-de-dispensacao": _sem_linha(3), "SD33-painel-institucional-de-dispensacao": [],
"SD34-pacientes-ativos-e-alerta-de-inativacao": _sem_linha(3), "SD34-pacientes-ativos-e-alerta-de-inativacao": [],
"SD35-programacao-estoque-e-faltas": _sem_linha(2), "SD35-programacao-estoque-e-faltas": [],
"SD36-analise-de-processos-ceaf-com-ia-piloto": _sem_linha(3), "SD36-analise-de-processos-ceaf-com-ia-piloto": [],
} }
# O que a pessoa escreveria hoje em `linha_os`, copiado da tabela §5 do contrato
# (banco de 2026-09-03). NÃO é de-para do exportador — ele não tem nenhum, por
# decisão da gestão. Serve só para os testes de contrato e de estrutura rodarem
# sobre um payload completo, como se a SD já estivesse preenchida.
LINHA_DE_TESTE = {"I-02": "1090-L1", "I-03": "2587-L1", "I-04": "2587-L2", "I-05": "2587-L3"}
def com_linhas(sd: dict) -> dict:
"""A SD como ficaria depois de a pessoa preencher `linha_os` no formulário."""
for e in sd.get("entregaveis") or []:
if isinstance(e, dict) and not e.get("linha_os"):
e["linha_os"] = LINHA_DE_TESTE.get(e.get("item"), "1090-L1")
return sd
# Regras do sd-schema.json que NÃO são do contrato da carga — a decisão da # Regras do sd-schema.json que NÃO são do contrato da carga — a decisão da
# gestão (2026-09-03) é deixar o importador rejeitar texto fora do teto, e a # gestão (2026-09-03) é deixar o importador rejeitar texto fora do teto, e a
# régua de 3..5 bullets de aderência é redação do repositório, não da carga. # régua de 3..5 bullets de aderência é redação do repositório, não da carga.
@@ -143,8 +131,8 @@ def estrutura_intacta(canon) -> None:
for f in sds_reais(): for f in sds_reais():
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True) sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
ref_path = RAIZ / "scripts/backlog/sd" / (str(sd.get("sd", "")).replace("·", "-") + ".json") ref_path = RAIZ / "scripts/backlog/sd" / (str(sd.get("sd", "")).replace("·", "-") + ".json")
if not ref_path.exists() or analisar(com_linhas(sd), corpo, canon)[1]: if not ref_path.exists() or analisar(sd, corpo, canon)[1]:
continue # sem JSON antigo para comparar, ou pendente por outro motivo continue # sem JSON antigo para comparar, ou pendente
pares += 1 pares += 1
referencia = json.loads(ref_path.read_text(encoding="utf-8")) referencia = json.loads(ref_path.read_text(encoding="utf-8"))
novo = json.loads(serializar(montar(sd, corpo, canon))) novo = json.loads(serializar(montar(sd, corpo, canon)))
@@ -254,16 +242,25 @@ def acervo(canon) -> None:
except ExportacaoReprovada as exc: except ExportacaoReprovada as exc:
checar(len(exc.pendencias) == len(pend_app), f"{nome}: montar() levanta as mesmas pendências") checar(len(exc.pendencias) == len(pend_app), f"{nome}: montar() levanta as mesmas pendências")
# Como a SD fica depois de a pessoa preencher `linha_os`: se só faltava if pend_app:
# isso, o payload completo tem de cumprir o contrato e sair igual nos continue # pendente — coberto acima
# dois caminhos.
com_linhas(sd_cli), com_linhas(sd_app)
if analisar(sd_app, corpo_app, canon)[1]:
continue # pendente por outro motivo — coberto acima
s_cli = serializar(montar(sd_cli, corpo_cli, canon)) s_cli = serializar(montar(sd_cli, corpo_cli, canon))
s_app = serializar(montar(sd_app, corpo_app, canon)) s_app = serializar(montar(sd_app, corpo_app, canon))
checar(s_cli == s_app, f"{nome}: mesmos bytes (com linha_os preenchido)") checar(s_cli == s_app, f"{nome}: mesmos bytes")
cumpre_contrato(nome, json.loads(s_app), canon) payload = json.loads(s_app)
cumpre_contrato(nome, payload, canon)
# O que o .md não declara veio da tabela — e tem de ser o padrão do
# item, não qualquer linha. O que o .md declara vai como está.
por_n = {e["n"]: e["ordem_servico"]["linha"] for e in payload["entregaveis"]}
erradas = []
for e in sd_app["entregaveis"]:
esperada = (str(e["linha_os"]).strip() if e.get("linha_os")
else linha_da_tabela(canon.linhas_os, e.get("item"))[0])
if por_n.get(e["n"]) != esperada:
erradas.append((e["n"], por_n.get(e["n"]), esperada))
da_tabela = sum(1 for e in sd_app["entregaveis"] if not e.get("linha_os"))
checar(not erradas, f"{nome}: linha de OS = declarada no .md ou padrão da tabela "
f"({da_tabela} da tabela)" + (f" — erradas: {erradas}" if erradas else ""))
for nome in ESPERADO: for nome in ESPERADO:
if nome not in vistos: if nome not in vistos:
checar(False, f"{nome}: consta em ESPERADO mas não existe mais no acervo") checar(False, f"{nome}: consta em ESPERADO mas não existe mais no acervo")
@@ -307,13 +304,61 @@ def regras_sinteticas(canon) -> None:
got = _pend(canon, entregaveis, topo) got = _pend(canon, entregaveis, topo)
checar(sorted(got, key=str) == sorted(esperado, key=str), f"{desc}: {got}") checar(sorted(got, key=str) == sorted(esperado, key=str), f"{desc}: {got}")
caso("linha_os ausente", ENT_SEM_LINHA, [("entregavel", 0, "linha_os")]) # Linha de OS. Declarada manda; ausente, contrato/linhas-os.yaml dá o
caso("linha_os nula", ENT_SEM_LINHA + " linha_os: null\n", [("entregavel", 0, "linha_os")]) # padrão do item (fallback de 2026-09-04); fora do formato é pendência.
itens_importaveis = sorted(i for i in canon.itens["itens"] if i != "I-01")
checar(canon.linhas_os is not None
and all(linha_da_tabela(canon.linhas_os, i)[0] for i in itens_importaveis),
f"a tabela real tem linha padrão para todo item importável ({', '.join(itens_importaveis)})")
e = montar(*_sd(ENT_SEM_LINHA), canon)["entregaveis"][0]
checar(e["ordem_servico"]["linha"] == linha_da_tabela(canon.linhas_os, "I-02")[0] == "1090-L1",
"linha_os ausente: sai o padrão de I-02 na tabela (1090-L1), sem pendência")
checar(_pend(canon, ENT_SEM_LINHA + " linha_os: null\n") == [],
"linha_os nula: idem — nulo é ausência")
caso("linha_os fora do formato", ENT_SEM_LINHA + ' linha_os: "L1"\n', [("entregavel", 0, "linha_os")]) caso("linha_os fora do formato", ENT_SEM_LINHA + ' linha_os: "L1"\n', [("entregavel", 0, "linha_os")])
# Só formato: existência, item e status são do banco, na carga. Uma linha # Só formato: existência, item e status são do banco, na carga. Uma linha
# que o repositório não conhece PASSA aqui, de propósito. # que o repositório não conhece PASSA aqui, de propósito — e a tabela não
# é régua do que foi declarado.
checar(_pend(canon, ENT_SEM_LINHA + ' linha_os: "9999-L7"\n') == [], checar(_pend(canon, ENT_SEM_LINHA + ' linha_os: "9999-L7"\n') == [],
"linha_os bem formada que o repositório não conhece passa — quem confere é o banco") "linha_os bem formada que a tabela não conhece passa — quem confere é o banco")
e = montar(*_sd(ENT_SEM_LINHA + ' linha_os: "1091-L1"\n'), canon)["entregaveis"][0]
checar(e["ordem_servico"]["linha"] == "1091-L1",
"linha declarada NÃO é trocada pelo padrão da tabela — o .md manda")
# A tabela em outras formas: ausente, ambígua, sem o item, com uma linha só.
sem_tabela = Canonico(canon.itens, None)
caso_t = lambda c, ent: [(p.escopo, p.indice, p.campo) for p in analisar(*_sd(ent), c)[1]]
checar(caso_t(sem_tabela, ENT_SEM_LINHA) == [("entregavel", 0, "linha_os")],
"sem o arquivo linhas-os.yaml, `linha_os` ausente volta a ser pendência (como antes)")
checar(caso_t(sem_tabela, ENT_OK) == [], "sem o arquivo, a linha declarada segue passando")
ambigua = Canonico(canon.itens, preparar_linhas_os(
{"linhas": [{"linha": "1090-L1", "item": "I-02"}, {"linha": "1091-L1", "item": "I-02"}]}))
p = analisar(*_sd(ENT_SEM_LINHA), ambigua)[1]
checar([(x.campo, x.opcoes) for x in p] == [("linha_os", ["1090-L1", "1091-L1"])]
and "sem `padrao`" in p[0].mensagem,
"duas linhas para o item e nenhuma `padrao`: pendência nomeando as candidatas")
so_i05 = Canonico(canon.itens, preparar_linhas_os({"linhas": [{"linha": "2587-L3", "item": "I-05"}]}))
p = analisar(*_sd(ENT_SEM_LINHA), so_i05)[1]
checar([(x.escopo, x.indice, x.campo) for x in p] == [("entregavel", 0, "linha_os")]
and "não tem linha para I-02" in p[0].mensagem,
"item fora da tabela: pendência que diz que a tabela não o tem")
checar(caso_t(so_i05, ENT_SEM_LINHA.replace("I-02", "I-05")) == [],
"item com UMA linha só na tabela: ela é o padrão, sem precisar da marca")
for desc, bruto in (
("dois `padrao: true` no mesmo item",
{"linhas": [{"linha": "1090-L1", "item": "I-02", "padrao": True},
{"linha": "1091-L1", "item": "I-02", "padrao": True}]}),
("linha repetida", {"linhas": [{"linha": "1090-L1", "item": "I-02"}, {"linha": "1090-L1", "item": "I-05"}]}),
("linha fora do formato", {"linhas": [{"linha": "L1", "item": "I-02"}]}),
("`padrao` que não é booleano", {"linhas": [{"linha": "1090-L1", "item": "I-02", "padrao": "sim"}]}),
("entrada sem item", {"linhas": [{"linha": "1090-L1"}]}),
("sem a lista `linhas`", {"atualizado_em": "2026-09-04"}),
):
try:
preparar_linhas_os(bruto)
checar(False, f"tabela malformada ({desc}) reprova na leitura")
except ValueError as exc:
checar(True, f"tabela malformada ({desc}) reprova na leitura — {exc}")
caso("I-01 (Licença) não entra pela carga", ENT_OK.replace("I-02", "I-01"), [("entregavel", 0, "item")]) caso("I-01 (Licença) não entra pela carga", ENT_OK.replace("I-02", "I-01"), [("entregavel", 0, "item")])
caso("item desconhecido", ENT_OK.replace("I-02", "I-07"), [("entregavel", 0, "item")]) caso("item desconhecido", ENT_OK.replace("I-02", "I-07"), [("entregavel", 0, "item")])
caso("tipo Manutenção para a carga", ENT_OK.replace("Descoberta", "Manutenção"), [("entregavel", 0, "tipo")]) caso("tipo Manutenção para a carga", ENT_OK.replace("Descoberta", "Manutenção"), [("entregavel", 0, "tipo")])
@@ -407,8 +452,8 @@ def schema_valida(canon) -> None:
for f in sds_reais(): for f in sds_reais():
nome = f.parent.name nome = f.parent.name
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True) sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
if analisar(com_linhas(sd), corpo, canon)[1]: if analisar(sd, corpo, canon)[1]:
continue # não exporta nem com linha_os preenchido — não há JSON a validar continue # não exporta — não há JSON a validar
queixa = None queixa = None
try: try:
jsonschema.validate(json.loads(serializar(montar(sd, corpo, canon))), esquema) jsonschema.validate(json.loads(serializar(montar(sd, corpo, canon))), esquema)