Compare commits
3
Commits
4ef1a7a4c8
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
382567b929 | ||
|
|
62ec768d0c | ||
|
|
a4b1374fad |
@@ -17,3 +17,6 @@ scripts/backlog
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
.dockerignore
|
||||
.env
|
||||
.env.example
|
||||
.gitattributes
|
||||
|
||||
@@ -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
|
||||
@@ -3,3 +3,8 @@
|
||||
# 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.
|
||||
*.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
@@ -8,7 +8,8 @@ scripts/backlog/
|
||||
|
||||
# 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
|
||||
# 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/
|
||||
|
||||
# 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.
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# Variáveis locais do compose — .env.example é o modelo versionado.
|
||||
.env
|
||||
|
||||
+47
-9
@@ -1,11 +1,26 @@
|
||||
# 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ê
|
||||
# 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
|
||||
# são dados do cliente, 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".
|
||||
# clientes/ses-mg/contrato/itens.yaml em tempo de execução (regras_sd.carregar_canonico),
|
||||
# consulta contrato/linhas-os.yaml se existir (fallback da linha de OS; ausente,
|
||||
# `linha_os` é exigido no SD.md) e grava SD.md de volta em
|
||||
# clientes/ses-mg/projetos/ (escrita_sd.gravar). Esses são dados do cliente,
|
||||
# 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 .
|
||||
# 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.
|
||||
# LANG/LC_ALL em UTF-8: identificadores de SD trazem "·" (P2·SD8) e o
|
||||
# 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 \
|
||||
PYTHONDONTWRITEBYTECODE=1 \
|
||||
LANG=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
|
||||
|
||||
# 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.
|
||||
# tzdata: zoneinfo para o TZ acima ter efeito (a slim não garante que venha).
|
||||
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/*
|
||||
|
||||
# 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).
|
||||
# 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
|
||||
# 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
|
||||
# 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
@@ -25,18 +25,28 @@ botão nenhum.
|
||||
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
|
||||
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
|
||||
|
||||
import hmac
|
||||
import io
|
||||
import os
|
||||
import secrets
|
||||
import sys
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
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 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}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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():
|
||||
return carregar_canonico()
|
||||
|
||||
@@ -362,12 +450,18 @@ def revisar():
|
||||
sess = ATUAL["sessao"]
|
||||
if sess is None:
|
||||
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(
|
||||
"revisar.html",
|
||||
s=sess,
|
||||
rotulo=sess.rotulo_estado(),
|
||||
formulario=montar_formulario(sess),
|
||||
previa=sess.previa or {},
|
||||
linhas_da_tabela=da_tabela,
|
||||
aviso=ATUAL.pop("aviso", None),
|
||||
)
|
||||
|
||||
|
||||
+42
-1
@@ -26,7 +26,38 @@ from pathlib import Path
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
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
|
||||
@@ -56,6 +87,10 @@ class Sessao:
|
||||
caminho_origem: Path | None = None
|
||||
ultimo_erro: 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:
|
||||
"""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
|
||||
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.
|
||||
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.estado = PRONTA if not self.pendencias else COM_PENDENCIAS
|
||||
self.exportada_em = None
|
||||
|
||||
+17
-3
@@ -129,6 +129,11 @@ header h1 {
|
||||
}
|
||||
.cabecalho { display: flex; justify-content: space-between; align-items: center;
|
||||
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 {
|
||||
font-size: 1.05rem; line-height: 1; padding: 0.35rem 0.65rem; flex: none;
|
||||
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.radio { display: inline-flex; align-items: center; gap: 0.35rem; margin-right: 1rem;
|
||||
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;
|
||||
border: 1px solid var(--linha); border-radius: 7px; font: inherit;
|
||||
font-size: 0.88rem; color: var(--tinta); background: var(--papel);
|
||||
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);
|
||||
}
|
||||
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);
|
||||
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; }
|
||||
table.entregaveis td.num { font-family: var(--fonte-mono); font-size: 0.78rem;
|
||||
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; }
|
||||
|
||||
+11
-2
@@ -31,9 +31,17 @@
|
||||
<header>
|
||||
<div class="cabecalho">
|
||||
<h1>Solicitação de Demanda — enviar · corrigir · exportar</h1>
|
||||
<button type="button" id="alternar-tema" class="discreto" title="Alternar tema"
|
||||
aria-label="Alternar tema claro e escuro">◐</button>
|
||||
<div class="acoes-cabecalho">
|
||||
<button type="button" id="alternar-tema" class="discreto" title="Alternar tema"
|
||||
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>
|
||||
{% block estados %}
|
||||
<div class="estados">
|
||||
{# 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
|
||||
@@ -46,6 +54,7 @@
|
||||
{% endif %}
|
||||
<span class="pastilha {{ 'atual' if estado_atual == 'exportada' }}">3 JSON exportado</span>
|
||||
</div>
|
||||
{% endblock %}
|
||||
</header>
|
||||
|
||||
<main id="topo">
|
||||
|
||||
@@ -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 %}
|
||||
@@ -111,7 +111,7 @@
|
||||
<td>{{ e.titulo }}</td>
|
||||
<td>{{ e.tipo_entrega }}</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_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>
|
||||
@@ -126,6 +126,14 @@
|
||||
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;
|
||||
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>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
+53
-22
@@ -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
|
||||
# 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:
|
||||
# ── MODO 1 · Application (Dockerfile) — o que está em produção (2026-09-04) ──
|
||||
#
|
||||
# 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
|
||||
# início do container e ajusta o dono do volume para o usuário `sd` antes de
|
||||
# derrubar privilégio e subir o gunicorn — ver entrypoint.sh e o ENTRYPOINT
|
||||
# no Dockerfile. (Uma reconsequência: cada `scp` novo devolve os arquivos ao
|
||||
# usuário do ssh no host; some sozinho no próximo restart do container.)
|
||||
# Depois, no Coolify → Environment Variables, aponte o volume para lá:
|
||||
# Persistent storage → Add mount → "Directory mount"
|
||||
# Source Path: /data/coolify/applications/<uuid-da-app>/clientes
|
||||
# (o próprio Coolify sugere esse caminho; ele sobrevive
|
||||
# a redeploy — é a pasta de storage da app, não o clone)
|
||||
# Destination Path: /app/clientes
|
||||
# Mesmo esquema para _derivados, só se for usar a linha de comando.
|
||||
#
|
||||
# CLIENTES_DIR=/dados/exporta-sd/clientes
|
||||
# DERIVADOS_DIR=/dados/exporta-sd/_derivados # só se usar a linha de comando
|
||||
# Dados: a pasta do host nasce VAZIA. Copie o conteúdo para dentro dela —
|
||||
# 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
|
||||
# compose cria o diretório VAZIO sem avisar e o primeiro upload morre com
|
||||
# "Arquivo canônico ausente".
|
||||
# scp -r "D:/Exporta-SD/clientes/ses-mg" usuario@vps:/data/coolify/applications/<uuid>/clientes/
|
||||
#
|
||||
# Resto do painel: porta exposta 5000, réplicas 1 (ver o comentário de -w 1 no
|
||||
# Dockerfile), e o domínio o Coolify publica via Traefik.
|
||||
# e confira no host: ls /data/coolify/applications/<uuid>/clientes/ses-mg/contrato/
|
||||
# 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:
|
||||
exporta-sd:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
image: exporta-sd:local
|
||||
# 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.
|
||||
# Para rodar fora do Coolify, troque por: ports: ["5000:5000"].
|
||||
expose:
|
||||
- "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:
|
||||
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:
|
||||
# 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
|
||||
# código, que é o WORKDIR /app. Sem esta montagem, a tela inicial abre
|
||||
# e o primeiro upload falha com "Arquivo canônico ausente".
|
||||
# código, que é o WORKDIR /app.
|
||||
- ${CLIENTES_DIR:-./clientes}:/app/clientes
|
||||
# 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.
|
||||
|
||||
@@ -21,6 +21,9 @@ METODOS = RAIZ / "metodos"
|
||||
DERIVADOS = RAIZ / "_derivados"
|
||||
|
||||
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ê
|
||||
# portfólio, carteira ou catálogo lê o resultado de `make derivar` — os
|
||||
|
||||
+39
-14
@@ -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,
|
||||
saem `null` pelo mesmo motivo.
|
||||
|
||||
O que NÃO é derivado, por decisão da gestão (2026-09-03): `ordem_servico.linha`.
|
||||
OS e linha são cadastro do sistema de gestão e mudam durante o ano; um de-para
|
||||
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 ausente é pendência. Existência, item e status quem confere é o banco, na
|
||||
carga — que recusa nomeando o que falta.
|
||||
O que NÃO é derivado: `ordem_servico.linha`. OS e linha são cadastro do sistema
|
||||
de gestão e mudam durante o ano. Quem emite a SD declara `linha_os` no
|
||||
entregável, copiado da tela da OS; aqui só o formato `{OS}-L{n}` é conferido, e
|
||||
existência, item e status quem confere é o banco, na carga — que recusa
|
||||
nomeando o que falta. AUSENTE, a tabela contrato/linhas-os.yaml (se existir)
|
||||
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
|
||||
tetos de texto da §2/§3/§4 (nome ≤ 160, objetivo ≤ 4000 etc.) — o importador
|
||||
@@ -85,6 +88,7 @@ from regras_sd import (
|
||||
enquadramento_tr,
|
||||
janela_dias,
|
||||
ler_sd_arquivo,
|
||||
linha_da_tabela,
|
||||
perfil_do_cadastro,
|
||||
secao,
|
||||
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.")
|
||||
|
||||
# -- linha de OS -----------------------------------------------------------
|
||||
# Declarada no SD.md por quem emite a SD, e só o formato é conferido
|
||||
# aqui. OS e linha são cadastro do sistema de gestão e mudam durante o
|
||||
# ano; quem sabe se existem é o banco, na carga. Não há de-para local.
|
||||
# Declarada no SD.md: vai como está, só o formato é conferido — OS e
|
||||
# linha são cadastro do sistema de gestão, e quem sabe se existem é o
|
||||
# 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
|
||||
queixa = conferir_formato_linha_os(e.get("linha_os"))
|
||||
if queixa:
|
||||
falta("linha_os", queixa)
|
||||
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:
|
||||
linha = str(e.get("linha_os")).strip()
|
||||
queixa = conferir_formato_linha_os(declarada)
|
||||
if queixa:
|
||||
falta("linha_os", queixa)
|
||||
else:
|
||||
linha = str(declarada).strip()
|
||||
|
||||
# -- listas filhas -----------------------------------------------------------
|
||||
for campo in ("atividades", "criterios_aceite"):
|
||||
@@ -695,8 +715,13 @@ def main() -> int:
|
||||
print(f" ERRO {f}: {exc}", file=sys.stderr)
|
||||
falhas.append(f)
|
||||
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) "
|
||||
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:
|
||||
# Isolar sem nomear é pior que o estouro que substitui: o lote
|
||||
|
||||
+94
-12
@@ -30,6 +30,7 @@ except ImportError:
|
||||
sys.exit("Falta pyyaml. pip install pyyaml")
|
||||
|
||||
from caminhos import ITENS as ITENS_YAML
|
||||
from caminhos import LINHAS_OS as LINHAS_OS_YAML
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -40,23 +41,44 @@ 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.
|
||||
4.1.1.7.1, que alimenta `alocacoes`. Time-box e tarifa saíram daqui em
|
||||
2026-09-03: são tabelas do sistema de gestão, e é ele que deriva UST e
|
||||
valor na carga.
|
||||
|
||||
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.
|
||||
`linhas_os` é a tabela OS Mãe × linha × item (contrato/linhas-os.yaml), já
|
||||
preparada por preparar_linhas_os() — ou None quando o arquivo não existe.
|
||||
É FALLBACK, não fonte: só entra quando o entregável NÃO declara `linha_os`
|
||||
no SD.md. Linha declarada vai como está, e existência, item e status quem
|
||||
confere é o banco, na carga. Decisão de 2026-09-04, revendo a de
|
||||
2026-09-03 (que não admitia de-para nenhum): o .md continua mandando; a
|
||||
tabela poupa a digitação, e a aplicação grava no .md a linha que usou.
|
||||
"""
|
||||
itens: dict
|
||||
linhas_os: dict | None = None
|
||||
|
||||
|
||||
def carregar_canonico() -> Canonico:
|
||||
if not ITENS_YAML.exists():
|
||||
raise FileNotFoundError(f"Arquivo canônico ausente: {ITENS_YAML}")
|
||||
itens = yaml.safe_load(ITENS_YAML.read_text(encoding="utf-8"))
|
||||
return Canonico(itens)
|
||||
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
|
||||
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.
|
||||
no banco, §5) — a carga recusa nomeando o que falta e nada é gravado. A
|
||||
tabela linhas-os.yaml NÃO é conferida contra o valor declarado: ela é
|
||||
fallback para a ausência (ver linha_da_tabela), não régua do que a pessoa
|
||||
copiou da tela da OS. Decisões da gestão em 2026-09-03 e 2026-09-04.
|
||||
"""
|
||||
if linha in (None, ""):
|
||||
return ("`linha_os` ausente — ordem_servico.linha é obrigatório na carga e só quem "
|
||||
@@ -514,6 +536,66 @@ def conferir_formato_linha_os(linha) -> str | 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:
|
||||
"""Dias entre as datas, exclusivo — a mesma subtração que a carga faz."""
|
||||
return (fim - inicio).days
|
||||
|
||||
+39
-41
@@ -43,6 +43,13 @@ import servidor # noqa: E402
|
||||
|
||||
FALHAS: list[str] = []
|
||||
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"
|
||||
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():
|
||||
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()
|
||||
r = enviar(SD31)
|
||||
ck(not baixou(r), "o upload NÃO devolve download — devolve a tela de revisão")
|
||||
pag = texto(r)
|
||||
ck(estado_na_tela(pag) == "Com pendências" and "4 pendência(s)" in pag,
|
||||
"estado: com pendências — as 4 linhas de OS que o SD.md não declara")
|
||||
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(estado_na_tela(pag) == "Pronta para exportar",
|
||||
"estado: pronta — as 4 linhas que o SD.md não declara vieram de contrato/linhas-os.yaml")
|
||||
ck("PENDENTE" not in pag and 'id="form-pendencias"' not in pag,
|
||||
"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:],
|
||||
"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")
|
||||
ck(baixou(r) and "P7-SD31.json" in r.headers.get("Content-Disposition", ""), "download entregue")
|
||||
baixado = texto(r)
|
||||
p = json.loads(baixado)
|
||||
ck(p["sd"]["codigo"] == "P7·SD31", "é o JSON da SD certa")
|
||||
ck(all(e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]),
|
||||
"todo entregável saiu com a linha de OS preenchida")
|
||||
ck(all(e["item"] == "I-02" and e["ordem_servico"]["linha"] == "1090-L1" for e in p["entregaveis"]),
|
||||
"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"]),
|
||||
"status nos rótulos do contrato")
|
||||
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
|
||||
# esse arquivo pela linha de comando — tem de dar os mesmos bytes.
|
||||
# A mesma régua da CLI, dos dois lados: sobre o SD.md ORIGINAL (a CLI
|
||||
# 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:
|
||||
alvo = Path(tmp) / "SD.md"
|
||||
post("/salvar-sd", {"destino": str(alvo), "confirmo": "sim"})
|
||||
ck(alvo.exists() and alvo.read_text(encoding="utf-8").count("linha_os: 1090-L1") == 4,
|
||||
"o SD.md gravado leva as quatro linhas declaradas")
|
||||
txt = alvo.read_text(encoding="utf-8") if alvo.exists() else ""
|
||||
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")
|
||||
|
||||
|
||||
@@ -237,13 +232,11 @@ def po_e_objetivo():
|
||||
reiniciar()
|
||||
pag = texto(enviar(SD10))
|
||||
c = campos_do_form(pag)
|
||||
ck(set(c) == {"sd__po_responsavel", "corpo__objetivo"} | {f"e{i}__linha_os" for i in range(5)},
|
||||
f"PO, objetivo e as cinco linhas de OS no formulário — nada mais ({sorted(c)})")
|
||||
ck(set(c) == {"sd__po_responsavel", "corpo__objetivo"},
|
||||
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")
|
||||
c["sd__po_responsavel"] = "Guilherme Bernardino"
|
||||
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")
|
||||
p = json.loads(texto(post("/exportar")))
|
||||
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")
|
||||
ck([e["ordem_servico"]["linha"] for e in p["entregaveis"]]
|
||||
== ["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():
|
||||
@@ -330,7 +323,7 @@ def writeback():
|
||||
|
||||
|
||||
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: "P9·SD99"\nprojeto: "P9 · Teste"\ntitulo: "Sintética"\n'
|
||||
'po_responsavel: "Alguém"\nversao: "V1"\nestado: rascunho\n'
|
||||
@@ -341,28 +334,33 @@ def linha_de_os_no_formulario():
|
||||
reiniciar()
|
||||
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('<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["e0__linha_os"] = "9999-L7"
|
||||
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")))
|
||||
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()
|
||||
c = campos_do_form(texto(enviar(Path("sintetica.md"), "sintetica.md", sd_md)))
|
||||
c["e0__linha_os"] = ""
|
||||
pag = texto(post("/revisar", c))
|
||||
ck("`linha_os` ausente" in pag and not baixou(post("/exportar")),
|
||||
"em branco não há fallback nenhum: continua pendente e não exporta")
|
||||
ck("linha_os" not in servidor.ATUAL["sessao"].dados["entregaveis"][0],
|
||||
"e a chave `linha_os` saiu do frontmatter, em vez de virar string vazia")
|
||||
ck(estado_na_tela(pag) == "Pronta para exportar" and ">tabela</span>" in pag,
|
||||
"em branco, a tabela preenche o padrão de I-02 e a tela marca a origem")
|
||||
e0 = servidor.ATUAL["sessao"].dados["entregaveis"][0]
|
||||
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():
|
||||
print("\n10. Editar depois de exportar desfaz o rótulo 'exportada'")
|
||||
reiniciar()
|
||||
preencher_linhas(texto(enviar(SD31)))
|
||||
enviar(SD31) # pronta de saída: as linhas de OS vêm da tabela
|
||||
post("/exportar")
|
||||
ck(estado_na_tela(pagina()) == "JSON exportado", "exportada")
|
||||
post("/revisar", {"sd__titulo": "outro título"})
|
||||
|
||||
+93
-48
@@ -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
|
||||
de importação depende disso; só VALORES podem mudar.
|
||||
2. CONTRATO — cada SD que exporta cumpre CONTRATO-JSON-V2.md: linha de OS
|
||||
preenchida, UST = time-box × semanas(datas), valor = UST × tarifa, janela
|
||||
≤ 28 dias, vocabulário exato da §6, unicidades da §8.
|
||||
preenchida (declarada no .md ou, ausente, o padrão do item em
|
||||
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,
|
||||
campo a campo, e nenhuma outra.
|
||||
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 exporta_sd import ExportacaoReprovada, analisar, montar, serializar
|
||||
from regras_sd import (JANELA_MAXIMA_DIAS, PERFIS_DO_CADASTRO, STATUS_ENTREGAVEL, STATUS_SD,
|
||||
carregar_canonico, definir_secao, enquadramento_tr, ler_sd_arquivo,
|
||||
ler_sd_texto, secao, semanas_por_datas)
|
||||
Canonico, carregar_canonico, definir_secao, enquadramento_tr,
|
||||
ler_sd_arquivo, ler_sd_texto, linha_da_tabela, preparar_linhas_os,
|
||||
secao, semanas_por_datas)
|
||||
|
||||
FALHAS: list[str] = []
|
||||
|
||||
@@ -42,42 +44,28 @@ FALHAS: list[str] = []
|
||||
# pendências esperadas como (escopo, indice, campo) — lista vazia = exporta.
|
||||
# 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.
|
||||
def _sem_linha(n: int) -> list[tuple]:
|
||||
"""SD cujos n entregáveis ainda não declaram `linha_os` no SD.md."""
|
||||
return [("entregavel", i, "linha_os") for i in range(n)]
|
||||
|
||||
|
||||
#
|
||||
# Desde 2026-09-04 a falta de `linha_os` no .md deixou de ser pendência para os
|
||||
# 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 = {
|
||||
"SD8-mvp-vacina-em-dia": [("entregavel", 1, "semanas"), ("entregavel", 2, "semanas"),
|
||||
("entregavel", 7, "fim")],
|
||||
"SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")] + _sem_linha(5),
|
||||
"SD18-integracao-core-rnds-samu": _sem_linha(4),
|
||||
"SD10-crie": [("sd", None, "po_responsavel"), ("corpo", None, "objetivo")],
|
||||
"SD18-integracao-core-rnds-samu": [],
|
||||
"SD20-siscan-d1": [("sd", None, "po_responsavel")]
|
||||
+ [("entregavel", i, c) for i in range(5) for c in ("inicio", "fim")],
|
||||
"SD24-mvp-institucional": [("entregavel", 3, "semanas")],
|
||||
"SD27-design-thinking-oficina-superintendencias": _sem_linha(3),
|
||||
"SD31-fundacao-da-base-de-dispensacao": _sem_linha(4),
|
||||
"SD32-extracao-automatizada-d-1-do-sigaf": _sem_linha(4),
|
||||
"SD33-painel-institucional-de-dispensacao": _sem_linha(3),
|
||||
"SD34-pacientes-ativos-e-alerta-de-inativacao": _sem_linha(3),
|
||||
"SD35-programacao-estoque-e-faltas": _sem_linha(2),
|
||||
"SD36-analise-de-processos-ceaf-com-ia-piloto": _sem_linha(3),
|
||||
"SD27-design-thinking-oficina-superintendencias": [],
|
||||
"SD31-fundacao-da-base-de-dispensacao": [],
|
||||
"SD32-extracao-automatizada-d-1-do-sigaf": [],
|
||||
"SD33-painel-institucional-de-dispensacao": [],
|
||||
"SD34-pacientes-ativos-e-alerta-de-inativacao": [],
|
||||
"SD35-programacao-estoque-e-faltas": [],
|
||||
"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
|
||||
# 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.
|
||||
@@ -143,8 +131,8 @@ def estrutura_intacta(canon) -> None:
|
||||
for f in sds_reais():
|
||||
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
|
||||
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]:
|
||||
continue # sem JSON antigo para comparar, ou pendente por outro motivo
|
||||
if not ref_path.exists() or analisar(sd, corpo, canon)[1]:
|
||||
continue # sem JSON antigo para comparar, ou pendente
|
||||
pares += 1
|
||||
referencia = json.loads(ref_path.read_text(encoding="utf-8"))
|
||||
novo = json.loads(serializar(montar(sd, corpo, canon)))
|
||||
@@ -254,16 +242,25 @@ def acervo(canon) -> None:
|
||||
except ExportacaoReprovada as exc:
|
||||
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
|
||||
# isso, o payload completo tem de cumprir o contrato e sair igual nos
|
||||
# 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
|
||||
if pend_app:
|
||||
continue # pendente — coberto acima
|
||||
s_cli = serializar(montar(sd_cli, corpo_cli, canon))
|
||||
s_app = serializar(montar(sd_app, corpo_app, canon))
|
||||
checar(s_cli == s_app, f"{nome}: mesmos bytes (com linha_os preenchido)")
|
||||
cumpre_contrato(nome, json.loads(s_app), canon)
|
||||
checar(s_cli == s_app, f"{nome}: mesmos bytes")
|
||||
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:
|
||||
if nome not in vistos:
|
||||
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)
|
||||
checar(sorted(got, key=str) == sorted(esperado, key=str), f"{desc}: {got}")
|
||||
|
||||
caso("linha_os ausente", ENT_SEM_LINHA, [("entregavel", 0, "linha_os")])
|
||||
caso("linha_os nula", ENT_SEM_LINHA + " linha_os: null\n", [("entregavel", 0, "linha_os")])
|
||||
# Linha de OS. Declarada manda; ausente, contrato/linhas-os.yaml dá o
|
||||
# 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")])
|
||||
# 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') == [],
|
||||
"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("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")])
|
||||
@@ -407,8 +452,8 @@ def schema_valida(canon) -> None:
|
||||
for f in sds_reais():
|
||||
nome = f.parent.name
|
||||
sd, corpo = ler_sd_arquivo(f, preservar_comentarios=True)
|
||||
if analisar(com_linhas(sd), corpo, canon)[1]:
|
||||
continue # não exporta nem com linha_os preenchido — não há JSON a validar
|
||||
if analisar(sd, corpo, canon)[1]:
|
||||
continue # não exporta — não há JSON a validar
|
||||
queixa = None
|
||||
try:
|
||||
jsonschema.validate(json.loads(serializar(montar(sd, corpo, canon))), esquema)
|
||||
|
||||
Reference in New Issue
Block a user