Commit inicial: app de exportação da SD + deploy Docker/Coolify

Aplicação Flask (enviar SD.md -> corrigir pendências -> exportar JSON),
scripts de contrato (scripts/) e infraestrutura de deploy (Dockerfile,
docker-compose.yml) para VPS via Coolify.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-04 14:07:38 -03:00
co-authored by Claude Sonnet 5
commit 0d4fdd2533
29 changed files with 4268 additions and 0 deletions
+99
View File
@@ -0,0 +1,99 @@
"""
escrita_sd.py — regrava o SD.md preservando os comentários do frontmatter.
Por que round-trip e não yaml.safe_dump: os comentários do frontmatter carregam
decisão de contrato, não enfeite. O campo `estado` da P2·SD10 tem quatro linhas
de correção datada ("CORREÇÃO 17/08/2026 (gestão do contrato): o protótipo
navegável está EM CRIAÇÃO, não em produção como registrado em 11/08"). Um dump
comum apagaria isso sem deixar rastro, e o histórico da decisão morreria num
clique de "Salvar".
Nada aqui grava sozinho: quem chama mostra o diff primeiro e pede confirmação.
"""
from __future__ import annotations
import difflib
import io
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
from caminhos import PROJETOS, RAIZ # noqa: E402
def _yaml_rt():
from ruamel.yaml import YAML
y = YAML()
y.preserve_quotes = True
# Largura efetivamente infinita: o padrão do ruamel é 80 e requebraria
# linhas longas que hoje estão inteiras, produzindo um diff cheio de ruído
# em campos que ninguém tocou.
y.width = 4096
return y
def render_sd(dados, corpo: str) -> str:
"""Monta o texto completo do SD.md: frontmatter + prosa, verbatim."""
buf = io.StringIO()
_yaml_rt().dump(dados, buf)
frontmatter = buf.getvalue()
if not frontmatter.endswith("\n"):
frontmatter += "\n"
corpo = corpo or ""
if corpo and not corpo.startswith("\n"):
corpo = "\n" + corpo
if corpo and not corpo.endswith("\n"):
corpo += "\n"
return f"---\n{frontmatter}---\n{corpo}"
def destino_provavel(sd_id: str) -> Path | None:
"""O SD.md do repositório cujo campo `sd:` casa com o da sessão.
Só devolve caminho quando há UMA correspondência. Duas seria ambiguidade, e
escolher sozinho o arquivo que vai ser sobrescrito não é decisão de código.
"""
if not sd_id:
return None
import yaml
achados = []
for f in sorted(PROJETOS.glob("*/sds/*/SD.md")):
try:
linhas = f.read_text(encoding="utf-8").splitlines()
if not linhas or linhas[0].strip() != "---":
continue
fim = next((i for i, l in enumerate(linhas[1:], 1) if l.strip() == "---"), None)
if fim is None:
continue
fm = yaml.safe_load("\n".join(linhas[1:fim])) or {}
except Exception:
continue
if str(fm.get("sd", "")).strip() == str(sd_id).strip():
achados.append(f)
return achados[0] if len(achados) == 1 else None
def diff_contra(destino: Path, texto_novo: str) -> str:
atual = destino.read_text(encoding="utf-8") if destino.exists() else ""
return "".join(difflib.unified_diff(
atual.splitlines(keepends=True),
texto_novo.splitlines(keepends=True),
fromfile=f"a/{destino.name} (no disco)",
tofile=f"b/{destino.name} (da sessão)",
n=3,
)) or "(sem diferenças)"
def dentro_do_repositorio(destino: Path) -> bool:
try:
destino.resolve().relative_to(RAIZ)
return True
except ValueError:
return False
def gravar(destino: Path, texto: str) -> None:
destino.parent.mkdir(parents=True, exist_ok=True)
destino.write_text(texto, encoding="utf-8")
+474
View File
@@ -0,0 +1,474 @@
#!/usr/bin/env python3
"""
servidor.py — a interface do fluxo enviar SD.md → corrigir o que falta → exportar.
python3 app/servidor.py # http://127.0.0.1:5000
O fluxo é curto de propósito: o upload lê o SD.md, o exportador o analisa
contra o contrato da carga (CONTRATO-JSON-V2.md) e a tela mostra o resumo do
que vai sair. Se falta algo, aparece um formulário SÓ com os campos pendentes;
corrigido, o botão Exportar libera. Nada mais.
A INVARIANTE desta aplicação, e o critério pelo qual ela deve ser julgada:
nenhuma rota entrega um caminho de arquivo ao exporta_sd.py, e nenhuma
chama o main() dele. A exportação chama montar() com o dicionário DA
SESSÃO — o mesmo objeto que o formulário editou.
Duas consequências. Primeira: "upload → download automático" é impossível por
construção — não existe caminho de código do /upload até o send_file; o único
que gera JSON é o /exportar, e ele só roda depois de um clique do usuário.
Segunda: o /exportar não confia no botão nem no estado da sessão — chama
montar(), que reprova sozinho se houver pendência. Um POST direto não passa por
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).
"""
from __future__ import annotations
import io
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
import escrita_sd
import sessao as S
from exporta_sd import ExportacaoReprovada, Pendencia, montar, serializar
from regras_sd import (ESTADOS_SD, TIPOS_IMPORTAVEIS, carregar_canonico, definir_secao,
ler_sd_texto, secao)
app = Flask(__name__)
# Chave YAML que existe com valor nulo — `processo_sei: null`, `linha_os: null` —
# renderizava como o texto "None" no campo do formulário, e voltava do POST como
# a STRING "None". Um "Salvar SD.md" depois disso gravaria `linha_os: "None"`.
# Vale para todo o template de uma vez, e não campo a campo.
app.jinja_env.finalize = lambda v: "" if v is None else v
# Dias da janela, para a tabela de prévia — a mesma subtração que a carga faz
# para derivar semanas (e, a partir delas, a UST).
app.jinja_env.globals["dias"] = lambda a, b: (date.fromisoformat(b) - date.fromisoformat(a)).days
# Uma SD por vez, em memória de processo. Ver docstring.
ATUAL: dict = {"sessao": None, "aviso": None, "erro": None}
def canonico():
return carregar_canonico()
# ---------------------------------------------------------------------------
# Conversão formulário → dados
# ---------------------------------------------------------------------------
# Tipo YAML de cada chave do frontmatter que o formulário pode editar. O que não
# está aqui é texto.
TIPO_DO_CAMPO = {
"n": "int", "semanas": "int", "ust": "int",
"inicio": "data", "fim": "data", "data_abertura": "data",
}
def _limpo(v) -> str:
return (v or "").strip()
def _como_valor(texto: str, tipo: str):
"""Converte o texto do formulário para o tipo do YAML.
Quando não converte, devolve o texto cru DE PROPÓSITO: quem reclama de data
ilegível ou semana não-inteira é o exportador, com mensagem própria e
apontando o entregável. Silenciar aqui — virando None, ou recusando o post
— tiraria do usuário a única explicação que ele ia receber.
"""
if tipo == "int":
try:
return int(texto)
except ValueError:
return texto
if tipo == "data":
try:
return date.fromisoformat(texto)
except ValueError:
return texto
return texto
def _aplicar(mapa, chave: str, texto: str, tipo: str = "texto") -> None:
"""Grava a chave no CommentedMap, ou a remove quando o campo vem vazio.
Campo esvaziado vira chave AUSENTE, e não string vazia: é assim que o
exportador entende "campo faltante" — e, para `semanas`/`ust`/`linha_os`,
é como o usuário diz "apague a declaração e derive". Só escreve quando o
valor mudou de fato — reatribuir um valor idêntico é oportunidade gratuita
de perder formatação e comentário de linha no writeback.
"""
atual = mapa.get(chave)
if not texto:
if chave in mapa and atual is not None:
del mapa[chave]
return
if isinstance(atual, str) and atual.strip() == texto.strip():
return
novo = _como_valor(texto, tipo)
if atual != novo or chave not in mapa:
mapa[chave] = novo
def _linhas(texto: str) -> list[str]:
return [l.strip() for l in (texto or "").splitlines() if l.strip()]
def _aplicar_lista(mapa, chave: str, texto: str) -> None:
itens = _linhas(texto)
atual = mapa.get(chave)
if not itens:
if chave in mapa and atual:
del mapa[chave]
return
if isinstance(atual, list) and [str(x).strip() for x in atual] == itens:
return
mapa[chave] = itens
def _artefatos_de_texto(texto: str) -> list[dict]:
"""Uma linha por artefato, no formato `nome | AAAA-MM-DD`."""
saida = []
for linha in _linhas(texto):
nome, sep, data = linha.partition("|")
art = {"nome": nome.strip()}
if sep and data.strip():
art["data"] = _como_valor(data.strip(), "data")
saida.append(art)
return saida
def _texto_de_artefatos(artefatos) -> str:
linhas = []
for a in (artefatos or []):
if isinstance(a, dict):
d = a.get("data")
linhas.append(f"{a.get('nome', '')} | {d}" if d else str(a.get("nome", "")))
else:
linhas.append(str(a))
return "\n".join(linhas)
def aplicar_formulario(sess: S.Sessao, form) -> None:
"""Escreve as edições NO OBJETO DA SESSÃO. É a única mutação dos dados.
Percorre as chaves que VIERAM no POST, e só elas. O formulário só traz os
campos pendentes; um laço sobre uma lista fixa de campos leria os ausentes
como "apagados" e o primeiro Salvar removeria do YAML tudo o que não estava
pendente. A chave segue Pendencia.chave: `sd__campo`, `e{i}__campo`,
`corpo__campo`.
"""
d = sess.dados
for chave in form.keys():
escopo, sep, campo = chave.partition("__")
if not sep or not campo:
continue
bruto = form.get(chave, "")
texto = _limpo(bruto)
if escopo == "sd":
_aplicar(d, campo, texto, TIPO_DO_CAMPO.get(campo, "texto"))
elif escopo == "corpo":
if campo == "objetivo":
sess.corpo = definir_secao(sess.corpo, "1", texto.replace("\r\n", "\n"))
elif escopo.startswith("e") and escopo[1:].isdigit():
i = int(escopo[1:])
ents = d.get("entregaveis") or []
if i >= len(ents) or not isinstance(ents[i], dict):
continue
e = ents[i]
if campo in ("atividades", "criterios_aceite"):
_aplicar_lista(e, campo, bruto)
elif campo == "artefatos":
novos = _artefatos_de_texto(bruto)
if novos:
if _texto_de_artefatos(e.get("artefatos")).strip() != _texto_de_artefatos(novos).strip():
e["artefatos"] = novos
elif "artefatos" in e and e.get("artefatos"):
del e["artefatos"]
else:
_aplicar(e, campo, texto, TIPO_DO_CAMPO.get(campo, "texto"))
# ---------------------------------------------------------------------------
# Da pendência ao controle do formulário
# ---------------------------------------------------------------------------
# Decisão de APRESENTAÇÃO, e por isso mora aqui e não no exportador: ele diz
# qual campo falhou, não com que controle se edita.
ROTULO = {
"sd": "Identificador da SD", "titulo": "Título", "po_responsavel": "PO responsável",
"versao": "Versão do documento", "estado": "Estado da SD", "data_abertura": "Data de abertura",
"objetivo": "Objetivo (Seção 1 do SD.md)",
"n": "Número (n)", "nome": "Nome", "tipo": "Tipo", "item": "Item",
"inicio": "Início", "fim": "Fim", "semanas": "Semanas declaradas", "ust": "UST declarada",
"linha_os": "Linha de OS — copie da tela da OS no sistema, formato {OS}-L{n}",
"atividades": "Backlog — um por linha",
"criterios_aceite": "Critérios de aceite — um por linha",
"artefatos": "Artefatos — nome | AAAA-MM-DD, um por linha",
}
TEXTAREA = {"objetivo", "atividades", "criterios_aceite", "artefatos"}
# Sem controle na tela: o conserto é estrutural e se faz no SD.md.
SO_MENSAGEM = {"entregaveis", "dependencias", "secao5", "alocacoes_desvio"}
PLACEHOLDER = {"inicio": "AAAA-MM-DD", "fim": "AAAA-MM-DD", "data_abertura": "AAAA-MM-DD",
"versao": "V1", "sd": "P2·SD8", "linha_os": "1090-L1"}
def _valor_atual(sess: S.Sessao, p: Pendencia):
if p.escopo == "corpo":
return secao(sess.corpo or "", "1") or "" if p.campo == "objetivo" else ""
fonte = sess.dados if p.escopo == "sd" else (sess.dados.get("entregaveis") or [])[p.indice]
if not isinstance(fonte, dict):
return ""
v = fonte.get(p.campo)
if p.campo == "artefatos":
return _texto_de_artefatos(v)
if p.campo in ("atividades", "criterios_aceite"):
return "\n".join(str(x) for x in (v or []))
return "" if v is None else v
def _opcoes(p: Pendencia) -> list | None:
if p.campo == "estado":
return list(ESTADOS_SD)
if p.campo == "tipo":
return list(TIPOS_IMPORTAVEIS)
if p.campo == "item":
return list(p.opcoes or [])
return None
def montar_formulario(sess: S.Sessao) -> list[dict]:
"""Agrupa as pendências por alvo (SD, cada entregável, corpo), um controle
por campo. Uma pendência de `semanas` traz junto `inicio` e `fim`: a
correção pode ser em qualquer um dos três, e a pessoa decide qual."""
grupos: dict[tuple, dict] = {}
for p in sess.pendencias:
if p.escopo == "entregavel":
gk = ("entregavel", p.indice)
titulo = f"Entregável {p.n}" if p.n not in (None, "") else f"Entregável #{p.indice + 1}"
elif p.escopo == "corpo":
gk, titulo = ("corpo", None), "Corpo do documento"
else:
gk, titulo = ("sd", None), "Cabeçalho da SD"
g = grupos.setdefault(gk, {"titulo": titulo, "campos": {}})
# Semanas, início e fim são um só problema visto de três lados: a janela
# que dá as semanas. Pendência em qualquer um traz os outros para a tela.
campos = [p.campo]
if p.campo in ("semanas", "inicio", "fim"):
campos = [c for c in ("semanas", "inicio", "fim") if c == p.campo or c != "semanas"]
for campo in campos:
c = g["campos"].get(campo)
if c is None:
chave = Pendencia(p.escopo, campo, "", indice=p.indice, n=p.n).chave
c = g["campos"][campo] = {
"chave": chave, "campo": campo,
"rotulo": ROTULO.get(campo, campo),
"controle": ("mensagem" if campo in SO_MENSAGEM
else "textarea" if campo in TEXTAREA
else "select" if _opcoes(Pendencia(p.escopo, campo, "")) is not None
else "texto"),
"valor": _valor_atual(sess, Pendencia(p.escopo, campo, "", indice=p.indice)),
"opcoes": _opcoes(p) if campo == p.campo else _opcoes(Pendencia(p.escopo, campo, "")),
"placeholder": PLACEHOLDER.get(campo, ""),
"mensagens": [], "derivado": None,
}
if campo == p.campo:
c["mensagens"].append(p.mensagem)
if p.derivado is not None:
c["derivado"] = p.derivado
return [{"titulo": g["titulo"], "campos": list(g["campos"].values())}
for g in grupos.values()]
# ---------------------------------------------------------------------------
# Rotas
# ---------------------------------------------------------------------------
@app.get("/")
def inicio():
if ATUAL["sessao"] is None:
return render_template("upload.html", estado=S.SEM_ARQUIVO,
rotulo=S.ROTULO_ESTADO[S.SEM_ARQUIVO],
erro=ATUAL.pop("erro", None))
return redirect(url_for("revisar"))
@app.post("/upload")
def upload():
arq = request.files.get("arquivo")
if arq is None or not arq.filename:
ATUAL["erro"] = "Nenhum arquivo selecionado."
return redirect(url_for("inicio"))
if not arq.filename.lower().endswith(".md"):
ATUAL["erro"] = (f"“{arq.filename}” não é um arquivo .md. "
"A SD é um Markdown com frontmatter YAML.")
return redirect(url_for("inicio"))
bruto = arq.read()
if not bruto.strip():
ATUAL["erro"] = f"“{arq.filename}” está vazio."
return redirect(url_for("inicio"))
try:
texto = bruto.decode("utf-8")
except UnicodeDecodeError:
ATUAL["erro"] = (f"“{arq.filename}” não está em UTF-8. "
"Salve o arquivo como UTF-8 e envie de novo.")
return redirect(url_for("inicio"))
try:
# preservar_comentarios=True: é este objeto que o writeback regrava, e os
# comentários do frontmatter carregam decisão de contrato.
dados, corpo = ler_sd_texto(texto, preservar_comentarios=True)
except Exception as exc:
ATUAL["erro"] = f"Não consegui ler “{arq.filename}”: {exc}"
return redirect(url_for("inicio"))
sess = S.Sessao(nome_arquivo=arq.filename, dados=dados, corpo=corpo)
sess.caminho_origem = escrita_sd.destino_provavel(str(dados.get("sd", "")))
ATUAL["sessao"] = sess
ATUAL["erro"] = None
reavaliar(sess)
# Redireciona para a tela de revisão — NUNCA para um download. É a única
# saída desta rota.
return redirect(url_for("revisar"))
def reavaliar(sess: S.Sessao) -> None:
try:
sess.reavaliar(canonico())
except Exception as exc:
sess.ultimo_erro = f"A análise falhou: {type(exc).__name__} — {exc}"
sess.pendencias = [Pendencia("sd", "entregaveis", f"a análise estourou: {exc}")]
sess.estado = S.COM_PENDENCIAS
@app.get("/revisar")
def revisar():
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
return render_template(
"revisar.html",
s=sess,
rotulo=sess.rotulo_estado(),
formulario=montar_formulario(sess),
previa=sess.previa or {},
aviso=ATUAL.pop("aviso", None),
)
@app.post("/revisar")
def salvar_revisao():
"""Aplica as correções e reavalia. Não exporta — só o /exportar exporta."""
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
sess.ultimo_erro = None
try:
aplicar_formulario(sess, request.form)
except Exception as exc:
sess.ultimo_erro = f"Não consegui aplicar as correções: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
reavaliar(sess)
return redirect(url_for("revisar"))
@app.post("/exportar")
def exportar():
"""A ÚNICA rota que gera JSON — e montar() reprova sozinho se houver pendência."""
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
sess.ultimo_erro = None
try:
# montar() recebe o DICIONÁRIO DA SESSÃO — o mesmo que o formulário
# editou. Nenhum caminho de arquivo, nenhuma releitura do SD.md.
payload = montar(sess.dados, sess.corpo, canonico())
texto = serializar(payload)
except ExportacaoReprovada as exc:
# Reavaliado no servidor: um POST direto não passa por botão nenhum.
sess.pendencias = exc.pendencias
sess.estado = S.COM_PENDENCIAS
sess.ultimo_erro = (f"Exportação recusada — {len(exc.pendencias)} pendência(s). "
"Nada foi baixado: um arquivo que o importador rejeita não é "
"uma exportação.")
return redirect(url_for("revisar"))
except Exception as exc:
sess.ultimo_erro = f"A geração do JSON falhou: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
sess.marcar_exportada()
nome = f"{str(sess.dados.get('sd', 'sd')).replace('·', '-')}.json"
return send_file(io.BytesIO(texto.encode("utf-8")), mimetype="application/json",
as_attachment=True, download_name=nome)
@app.get("/salvar-sd")
def salvar_sd_preview():
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
destino = Path(request.args.get("destino") or (sess.caminho_origem or ""))
try:
texto = escrita_sd.render_sd(sess.dados, sess.corpo)
except Exception as exc:
sess.ultimo_erro = f"Não consegui montar o SD.md: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
return render_template(
"salvar.html", s=sess, destino=str(destino) if str(destino) else "",
diff=escrita_sd.diff_contra(destino, texto) if str(destino) else "",
no_repositorio=bool(str(destino)) and escrita_sd.dentro_do_repositorio(destino),
rotulo=sess.rotulo_estado(),
)
@app.post("/salvar-sd")
def salvar_sd():
sess = ATUAL["sessao"]
if sess is None:
return redirect(url_for("inicio"))
destino = _limpo(request.form.get("destino"))
if not destino:
sess.ultimo_erro = "Informe o caminho de destino para salvar o SD.md."
return redirect(url_for("revisar"))
if request.form.get("confirmo") != "sim":
sess.ultimo_erro = "Gravação cancelada: a confirmação não foi marcada."
return redirect(url_for("revisar"))
try:
escrita_sd.gravar(Path(destino), escrita_sd.render_sd(sess.dados, sess.corpo))
except Exception as exc:
sess.ultimo_erro = f"Não consegui gravar {destino}: {type(exc).__name__} — {exc}"
return redirect(url_for("revisar"))
ATUAL["aviso"] = f"SD.md gravado em {destino}."
return redirect(url_for("revisar"))
@app.post("/reiniciar")
def reiniciar():
ATUAL["sessao"] = None
ATUAL["aviso"] = None
ATUAL["erro"] = None
return redirect(url_for("inicio"))
if __name__ == "__main__":
for stream in (sys.stdout, sys.stderr):
if hasattr(stream, "reconfigure"):
stream.reconfigure(encoding="utf-8", errors="replace")
print(" Exportação da SD em http://127.0.0.1:5000")
app.run(host="127.0.0.1", port=5000, debug=False)
+80
View File
@@ -0,0 +1,80 @@
"""
sessao.py — a fonte única da SD entre o upload e o download.
O problema que esta camada resolve: a SD é carregada uma vez, vive em memória,
é editada e reavaliada — e o exportador recebe ESSE objeto, não um caminho de
arquivo. Sem isto, a correção feita no formulário nunca chegaria ao JSON.
O que a sessão sabe: os dados (frontmatter, como CommentedMap do ruamel para o
writeback preservar comentários), a prosa, e o resultado da última análise do
exportador — a prévia do payload e a lista de pendências. Pendência vazia é a
única condição para exportar, e ela é reavaliada no servidor a cada POST: o
botão desabilitado é conforto de tela.
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.
"""
from __future__ import annotations
import sys
from dataclasses import dataclass, field
from datetime import datetime
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
# ---------------------------------------------------------------------------
# Estados — os que a tela precisa distinguir, e nenhum a mais
# ---------------------------------------------------------------------------
SEM_ARQUIVO = "sem_arquivo"
COM_PENDENCIAS = "com_pendencias"
PRONTA = "pronta"
EXPORTADA = "exportada"
ROTULO_ESTADO = {
SEM_ARQUIVO: "Arquivo não enviado",
COM_PENDENCIAS: "Com pendências",
PRONTA: "Pronta para exportar",
EXPORTADA: "JSON exportado",
}
@dataclass
class Sessao:
nome_arquivo: str
dados: dict # CommentedMap do ruamel — FONTE ÚNICA, mutada pelo formulário
corpo: str
estado: str = COM_PENDENCIAS
pendencias: list[Pendencia] = field(default_factory=list)
previa: dict | None = None
caminho_origem: Path | None = None
ultimo_erro: str | None = None
exportada_em: str | None = None
def reavaliar(self, canon: Canonico) -> None:
"""Roda o exportador em modo de análise sobre os dados COMO ESTÃO.
É 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.
"""
self.previa, self.pendencias = analisar(self.dados, self.corpo, canon)
self.estado = PRONTA if not self.pendencias else COM_PENDENCIAS
self.exportada_em = None
@property
def pode_exportar(self) -> bool:
return not self.pendencias
def marcar_exportada(self) -> None:
self.estado = EXPORTADA
self.exportada_em = datetime.now().isoformat(timespec="seconds")
def rotulo_estado(self) -> str:
return ROTULO_ESTADO.get(self.estado, self.estado)
+358
View File
@@ -0,0 +1,358 @@
/* Public Sans/IBM Plex Mono são carregadas via <link> em base.html, não por
@import aqui — @import serializa a busca (baixa o CSS, só então descobre e
busca a fonte) e travaria a primeira renderização enquanto tenta resolver
fonts.googleapis.com; o <link> deixa isso paralelo e falha mais barato numa
rede restrita. A pilha de fallback abaixo é system-ui: sem internet, o app
fica como antes, só sem o toque final. */
/* Tema claro — o padrão histórico da aplicação. Toda cor é token: o tema escuro
mais abaixo só redefine estes valores, e nenhuma regra do arquivo repete uma
cor crua. Cor crua no meio das regras é o que faz o tema escuro nascer com um
botão branco sobre fundo branco. */
:root {
--fonte: "Public Sans", system-ui, -apple-system, "Segoe UI", sans-serif;
--fonte-mono: "IBM Plex Mono", ui-monospace, Consolas, monospace;
--tinta: #1b1e25;
/* O par obrigatório de --tinta: o que se escreve EM CIMA dela quando ela é
fundo (pastilha do estado atual, botão primário). Fixar #fff aqui daria
branco sobre claro no tema escuro, onde --tinta é a cor clara. */
--tinta-contraste: #ffffff;
--fraco: #5a6270;
--linha: #dde2e8;
--fundo: #f1f3f6;
--papel: #ffffff;
/* Superfície aninhada — um nível acima do fundo, um abaixo do cartão.
Usada em itens de lista dentro de cartão (achado, bloco-tr) para que
eles se leiam como "dentro" do cartão, não como outro cartão. */
--papel-2: #f6f7f9;
--sombra: 0 1px 2px rgba(23, 27, 35, 0.06), 0 4px 10px rgba(23, 27, 35, 0.05);
--erro: #b3251d;
--erro-fundo: #fdedec;
--erro-borda: #f3c7c2;
--ok: #1b7a4d;
--ok-fundo: #eaf7f0;
--ok-borda: #c2e8d3;
--alerta: #92600a;
--alerta-fundo: #fdf4e0;
--alerta-borda: #efd9a0;
/* Linguagem de proveniência: tudo que o script decidiu por regra fica em
azul; tudo que exige julgamento (do agente, ou do humano confirmando)
fica em roxo. As mesmas duas cores aparecem na borda do cartão da
camada, no selo do cabeçalho e na caixa "Rodar o auditor-sd" — o mesmo
par se repete em qualquer lugar onde a pergunta é "isto foi provado ou
foi julgado?". */
--script: #2f5fa8;
--script-fundo: #eaf1fb;
--script-borda: #c8dbf3;
--agente: #6d3fa0;
--agente-fundo: #f3ecfa;
--agente-borda: #ddc7ef;
--diff-fundo: #1a1d23;
--diff-tinta: #e6e9ef;
--foco: #2f5fa8;
}
/* Tema escuro. Só tokens — nenhuma regra estrutural muda.
Grafite/azul-acinzentado, não preto puro: o fundo mais escuro daqui
(--fundo) ainda tem luminosidade perceptível, e o cartão (--papel) é
sempre um degrau mais claro que ele, nunca igual. As cores semânticas do
tema claro (#b3251d, #1b7a4d, #92600a) foram escolhidas para contrastar
com papel branco e desaparecem sobre fundo escuro; aqui elas sobem em
luminosidade, e os fundos correspondentes descem para tons de vinho,
verde-musgo e âmbar escuros — nunca preto com uma tinta de cor jogada
em cima. */
:root[data-tema="escuro"] {
--tinta: #e9ebf1;
--tinta-contraste: #14171d;
--fraco: #9aa1b0;
--linha: #383e4b;
--fundo: #1a1d23;
--papel: #23272f;
--papel-2: #2a2f39;
--sombra: 0 1px 0 rgba(0, 0, 0, 0.35);
--erro: #ff9891;
--erro-fundo: #3a1f20;
--erro-borda: #5c2d2c;
--ok: #7ad9a6;
--ok-fundo: #16302a;
--ok-borda: #275943;
--alerta: #f0c368;
--alerta-fundo: #362a17;
--alerta-borda: #5c4726;
--script: #89b1f4;
--script-fundo: #1f2b3d;
--script-borda: #33455f;
--agente: #cda3ef;
--agente-fundo: #2b2338;
--agente-borda: #453a5c;
--diff-fundo: #14171c;
--diff-tinta: #dfe3ea;
--foco: #89b1f4;
}
* { box-sizing: border-box; }
html { scroll-behavior: smooth; }
body {
margin: 0;
font: 15.5px/1.55 var(--fonte);
color: var(--tinta);
background: var(--fundo);
-webkit-font-smoothing: antialiased;
}
::selection { background: var(--script-borda); color: var(--tinta); }
a { color: var(--script); }
:focus-visible {
outline: 2px solid var(--foco);
outline-offset: 2px;
border-radius: 4px;
}
header {
background: var(--papel);
border-bottom: 1px solid var(--linha);
padding: 1.1rem 1.75rem 0.85rem;
}
header h1 {
font-size: 1.05rem; margin: 0; font-weight: 600; letter-spacing: -0.005em;
}
.cabecalho { display: flex; justify-content: space-between; align-items: center;
gap: 1rem; margin-bottom: 0.75rem; }
#alternar-tema {
font-size: 1.05rem; line-height: 1; padding: 0.35rem 0.65rem; flex: none;
border-radius: 999px;
}
.estados { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.pastilha {
font-size: 0.72rem; font-weight: 500; padding: 0.25rem 0.6rem 0.25rem 0.55rem;
border-radius: 999px; background: var(--papel-2); color: var(--fraco);
border: 1px solid var(--linha); transition: background 0.15s ease, color 0.15s ease;
}
.pastilha.atual {
background: var(--tinta); color: var(--tinta-contraste);
border-color: var(--tinta); font-weight: 600;
}
/* Etapa da auditoria ocupada pelo bypass de teste: cor de alerta, e não a tinta
cheia da etapa cumprida. A trilha não deve parecer completa quando não está. */
.pastilha.atual.bypass {
background: var(--alerta-fundo); color: var(--alerta); border-color: var(--alerta-borda);
}
/* Tudo depois da etapa atual ainda não aconteceu — traço pontilhado e sem
preenchimento, só pela posição na lista (irmão depois de .atual), sem
precisar de uma classe nova vinda do Jinja para cada pastilha. */
.pastilha.atual ~ .pastilha {
background: transparent; border-style: dashed; color: var(--fraco); opacity: 0.8;
}
main { max-width: 1180px; margin: 0 auto; padding: 1.5rem 1.75rem 4.5rem; }
footer { max-width: 1180px; margin: 0 auto; padding: 0 1.75rem 2rem; color: var(--fraco);
font-size: 0.8rem; }
footer p { margin: 0.3rem 0; }
h2 { font-size: 1.2rem; margin: 0 0 0.4rem; font-weight: 700; letter-spacing: -0.01em; }
h3 { font-size: 0.98rem; margin: 0 0 0.5rem; font-weight: 600; }
h4 { font-size: 0.85rem; margin: 1.1rem 0 0.5rem; font-weight: 600; }
.topo { display: flex; justify-content: space-between; align-items: flex-start; gap: 1rem; }
.cartao {
background: var(--papel); border: 1px solid var(--linha); border-radius: 10px;
padding: 1.15rem 1.3rem; margin-bottom: 1.1rem; box-shadow: var(--sombra);
/* O link do achado da Fase 1 rola até aqui — sem a margem, o topo da seção
encosta na borda da janela e o cabeçalho da seção fica colado. */
scroll-margin-top: 1rem;
}
.fase3 { border-top: 3px solid var(--tinta); }
.duas-colunas { display: grid; grid-template-columns: 1fr 1fr; gap: 1.1rem; }
@media (max-width: 960px) { .duas-colunas { grid-template-columns: 1fr; } }
.camada-script { border-top: 3px solid var(--script); }
.camada-agente { border-top: 3px solid var(--agente); }
.selo {
float: right; font-size: 0.66rem; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.05em; color: var(--fraco); background: var(--papel-2);
border: 1px solid var(--linha); border-radius: 999px; padding: 0.15rem 0.55rem;
}
/* O selo herda a cor da camada onde mora — nunca precisa de uma classe própria
no template, porque a pergunta que ele responde ("provado ou julgado?") já
está respondida pela seção que o envolve. */
.camada-script .selo { background: var(--script-fundo); color: var(--script); border-color: var(--script-borda); }
.camada-agente .selo { background: var(--agente-fundo); color: var(--agente); border-color: var(--agente-borda); }
.faixa { padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1.1rem; font-size: 0.9rem; }
.faixa.erro { background: var(--erro-fundo); border: 1px solid var(--erro-borda); color: var(--erro); }
.faixa.aviso { background: var(--ok-fundo); border: 1px solid var(--ok-borda); color: var(--ok); }
.faixa.alerta { background: var(--alerta-fundo); border: 1px solid var(--alerta-borda); color: var(--alerta); }
.nota { color: var(--fraco); font-size: 0.83rem; margin: 0.35rem 0; }
.limitacao { color: var(--alerta); }
.verde { color: var(--ok); font-weight: 500; }
.vermelho { color: var(--erro); font-weight: 500; }
.alerta-texto { color: var(--alerta); font-weight: 500; }
ul.achados, ul.bloqueios { list-style: none; padding: 0; margin: 0.6rem 0 0; }
ul.achados li {
font-size: 0.85rem; padding: 0.5rem 0.6rem; border-left: 3px solid var(--linha);
border-radius: 0 6px 6px 0; margin-bottom: 0.35rem; background: var(--papel-2);
}
/* Com a Fase 2 fora da tela o painel da Fase 1 ocupa a largura toda, e uma
coluna só de achados deixava metade da linha vazia. Grade, e não `columns`:
`columns` quebra um item no meio entre uma coluna e outra. */
ul.achados.em-colunas {
display: grid; grid-template-columns: 1fr 1fr; gap: 0 0.7rem; align-items: start;
}
@media (max-width: 760px) { ul.achados.em-colunas { grid-template-columns: 1fr; } }
li.nivel-erro { border-left-color: var(--erro); }
li.nivel-aviso { border-left-color: var(--alerta); }
.tag { font-weight: 700; font-size: 0.68rem; margin-right: 0.4rem; letter-spacing: 0.03em; }
.nivel-erro .tag { color: var(--erro); }
.nivel-aviso .tag { color: var(--alerta); }
.regra { font-family: var(--fonte-mono); font-size: 0.74rem;
color: var(--fraco); margin-right: 0.4rem; }
.ent { font-size: 0.72rem; background: var(--linha); border-radius: 4px;
padding: 0.05rem 0.35rem; margin-right: 0.35rem; }
.ir-para { font-size: 0.72rem; background: var(--linha); color: var(--tinta);
border-radius: 4px; padding: 0.05rem 0.35rem; margin-right: 0.35rem;
text-decoration: none; white-space: nowrap; transition: background 0.15s ease, color 0.15s ease; }
.ir-para:hover { background: var(--tinta); color: var(--tinta-contraste); }
ul.bloqueios li { font-size: 0.85rem; padding: 0.4rem 0.7rem 0.4rem 1rem;
border-left: 3px solid var(--erro); border-radius: 0 6px 6px 0;
margin-bottom: 0.35rem; background: var(--erro-fundo); }
ol.checklist { font-size: 0.83rem; color: var(--fraco); padding-left: 1.2rem; }
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 {
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 {
border-color: var(--fraco);
}
input[type=text]:focus, input:not([type]):focus, select:focus, textarea:focus {
border-color: var(--foco);
outline: 2px solid var(--foco); outline-offset: 1px;
}
input[type=checkbox], input[type=radio] { accent-color: var(--tinta); }
::placeholder { color: var(--fraco); opacity: 0.7; }
input[type=file] {
display: block; width: 100%; margin: 0.3rem 0 0.9rem; font: inherit;
font-size: 0.85rem; color: var(--fraco);
}
input[type=file]::file-selector-button {
font: inherit; font-size: 0.85rem; font-weight: 500; padding: 0.5rem 1rem;
border-radius: 7px; border: 1px solid var(--linha); background: var(--papel);
color: var(--tinta); cursor: pointer; margin-right: 0.7rem;
transition: border-color 0.15s ease;
}
input[type=file]::file-selector-button:hover { border-color: var(--fraco); }
textarea { resize: vertical; }
textarea.mono { font-family: var(--fonte-mono); font-size: 0.8rem; }
.grade { display: grid; grid-template-columns: repeat(auto-fit, minmax(190px, 1fr)); gap: 0 0.9rem; }
.grade-3 { display: grid; grid-template-columns: repeat(3, 1fr); gap: 0 0.9rem; }
@media (max-width: 760px) { .grade-3 { grid-template-columns: 1fr; } }
button, .botao {
font: inherit; font-size: 0.85rem; font-weight: 500; padding: 0.5rem 1rem; border-radius: 7px;
border: 1px solid var(--linha); background: var(--papel); color: var(--tinta);
cursor: pointer; text-decoration: none; display: inline-block;
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease, transform 0.05s ease;
}
button:hover, .botao:hover { border-color: var(--fraco); }
button:active, .botao:active { transform: translateY(1px); }
button.primario { background: var(--tinta); color: var(--tinta-contraste);
border-color: var(--tinta); }
button.primario:hover { background: var(--fraco); border-color: var(--fraco); }
button.primario[disabled] { background: var(--papel-2); color: var(--fraco);
border-color: var(--linha); cursor: not-allowed; }
button.primario[disabled]:hover { background: var(--papel-2); border-color: var(--linha); }
button.discreto, .botao.discreto { color: var(--fraco); background: transparent; border-color: transparent; }
button.discreto:hover, .botao.discreto:hover { color: var(--tinta); background: var(--papel-2); border-color: var(--linha); }
.acoes { display: flex; gap: 0.65rem; align-items: center; flex-wrap: wrap; margin-top: 0.9rem; }
.acoes form { margin: 0; }
/* Salvar / Voltar ao Topo no fim de cada seção. A barra grudada no rodapé saiu:
o "Salvar edições e revalidar" mora agora na Fase 3, no topo da tela, e um
terceiro lugar para o mesmo botão só faria o usuário procurar. */
.rodape-secao { border-top: 1px solid var(--linha); padding-top: 0.7rem;
margin-top: 1rem; }
.entregavel { border-left: 3px solid var(--linha); }
.achado-item { border: 1px solid var(--linha); border-radius: 8px; padding: 0.7rem 0.8rem;
margin-bottom: 0.55rem; background: var(--papel-2); }
.achado-item.pendente { border-color: var(--alerta-borda); background: var(--alerta-fundo); }
.texto-achado { margin: 0 0 0.45rem; font-size: 0.85rem; }
.opcoes { margin-bottom: 0.45rem; }
.bloco-tr { border-left: 3px solid var(--script); background: var(--script-fundo);
padding: 0.7rem 0.9rem; border-radius: 0 8px 8px 0; margin-bottom: 0.65rem; }
.bloco-tr .tr-item { margin: 0 0 0.35rem; font-size: 0.88rem; }
ul.aderencia { margin: 0.35rem 0 0; padding-left: 1.1rem; font-size: 0.83rem; }
ul.aderencia li { margin-bottom: 0.2rem; }
pre.diff {
background: var(--diff-fundo); color: var(--diff-tinta); padding: 0.9rem 1rem;
border-radius: 8px; overflow-x: auto; font-family: var(--fonte-mono);
font-size: 0.78rem; line-height: 1.5; max-height: 480px;
}
code { font-family: var(--fonte-mono); font-size: 0.85em;
background: var(--papel-2); border: 1px solid var(--linha);
padding: 0.05rem 0.3rem; border-radius: 4px; }
details summary { cursor: pointer; font-size: 0.85rem; margin-bottom: 0.5rem; font-weight: 500; }
.agente { border: 1px dashed var(--agente-borda); background: var(--agente-fundo);
border-radius: 8px; padding: 0.8rem 0.9rem; margin-bottom: 0.9rem; }
.agente form { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; }
.agente .nota { margin: 0; flex: 1 1 16rem; }
/* A bifurcação Auditar / Não Auditar. Fica acima do painel do agente, no ponto
do fluxo em que a decisão é tomada, e visualmente separada dele: não é parte
da auditoria, é a alternativa a ela. */
.decisao-fase2 { border: 1px dashed var(--alerta-borda); border-radius: 8px;
padding: 0.8rem 0.9rem; margin-bottom: 0.9rem; background: var(--alerta-fundo); }
.decisao-fase2 form { display: flex; align-items: center; gap: 0.65rem; flex-wrap: wrap; }
.decisao-fase2 .nota, .decisao-fase2 p { margin: 0; flex: 1 1 16rem; }
.parecer-meta { margin-top: 0.7rem; border-top: 1px solid var(--linha); padding-top: 0.6rem; }
.parecer-meta p { font-size: 0.83rem; margin: 0.35rem 0; }
/* ---- Fluxo curto: pendências e resumo ---------------------------------- */
.pendencia { border-top: 1px solid var(--linha); padding-top: 0.7rem; margin-top: 0.7rem; }
.pendencia:first-child { border-top: 0; padding-top: 0; margin-top: 0; }
.pendencia .texto-achado { color: var(--alerta); }
.pendencia .tag { color: var(--alerta); }
.pendencia label .nota { display: inline; font-weight: 400; }
.resumo { gap: 0.6rem 0.9rem; margin-bottom: 0.9rem; }
.resumo > div { display: flex; flex-direction: column; font-size: 0.9rem; }
.resumo .nota { margin: 0 0 0.1rem; font-size: 0.72rem; text-transform: uppercase; letter-spacing: 0.04em; }
.tabela-rolavel { overflow-x: auto; }
table.entregaveis { width: 100%; border-collapse: collapse; font-size: 0.83rem; }
table.entregaveis th, table.entregaveis td {
text-align: left; padding: 0.45rem 0.55rem; border-bottom: 1px solid var(--linha);
vertical-align: top;
}
table.entregaveis th { font-size: 0.7rem; text-transform: uppercase; letter-spacing: 0.04em;
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; }
+73
View File
@@ -0,0 +1,73 @@
<!doctype html>
<html lang="pt-BR">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{% block titulo %}Exportação da SD{% endblock %}</title>
<!-- Antes da folha de estilo, e não no fim do body: aplicado depois, o tema
claro aparece por um quadro inteiro antes de escurecer. Sem tema gravado,
vale a preferência do sistema. -->
<script>
(function () {
try {
var t = localStorage.getItem("tema");
if (t !== "claro" && t !== "escuro") {
t = matchMedia("(prefers-color-scheme: dark)").matches ? "escuro" : "claro";
}
document.documentElement.dataset.tema = t;
} catch (e) {
document.documentElement.dataset.tema = "claro";
}
})();
</script>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link rel="stylesheet"
href="https://fonts.googleapis.com/css2?family=Public+Sans:wght@400;500;600;700&family=IBM+Plex+Mono:wght@400;500&display=swap">
<link rel="stylesheet" href="{{ url_for('static', filename='app.css') }}">
</head>
<body>
<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>
<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
mesma posição — são os dois resultados possíveis da mesma análise. #}
<span class="pastilha {{ 'atual' if estado_atual == 'sem_arquivo' }}">1 Arquivo</span>
{% if estado_atual == 'com_pendencias' %}
<span class="pastilha atual bypass">2 Com pendências</span>
{% else %}
<span class="pastilha {{ 'atual' if estado_atual == 'pronta' }}">2 Pronta para exportar</span>
{% endif %}
<span class="pastilha {{ 'atual' if estado_atual == 'exportada' }}">3 JSON exportado</span>
</div>
</header>
<main id="topo">
{% if erro %}<div class="faixa erro"><strong>Erro.</strong> {{ erro }}</div>{% endif %}
{% if aviso %}<div class="faixa aviso">{{ aviso }}</div>{% endif %}
{% block conteudo %}{% endblock %}
</main>
<footer>
<p>Regras da carga: <code>CONTRATO-JSON-V2.md</code> ·
exportação: <code>scripts/exporta_sd.py</code> (função <code>montar</code>) ·
OS e linha: <code>linha_os</code> declarado no SD.md, conferido pelo sistema na carga.</p>
</footer>
<script>
document.getElementById("alternar-tema").addEventListener("click", function () {
var raiz = document.documentElement;
var novo = raiz.dataset.tema === "escuro" ? "claro" : "escuro";
raiz.dataset.tema = novo;
try { localStorage.setItem("tema", novo); } catch (e) {}
});
</script>
</body>
</html>
+131
View File
@@ -0,0 +1,131 @@
{% extends "base.html" %}
{% set estado_atual = s.estado %}
{% set erro = s.ultimo_erro %}
{% block titulo %}{{ s.dados.get('sd', 'SD') }} — exportação{% endblock %}
{% block conteudo %}
<div class="topo">
<div>
<h2>{{ s.dados.get('sd', '(sem identificador)') }} — {{ s.dados.get('titulo', '') }}</h2>
<p class="nota">Origem: <code>{{ s.nome_arquivo }}</code> ·
estado: <strong>{{ rotulo }}</strong>
{% if s.exportada_em %} · exportada em {{ s.exportada_em }}{% endif %}</p>
</div>
<form method="post" action="{{ url_for('reiniciar') }}">
<button type="submit" class="discreto">Enviar outra SD</button>
</form>
</div>
<!-- ================= Ações =================
No topo: é daqui que sai a única ação que gera arquivo. O botão fica
desabilitado com pendência por conforto; o que garante é o servidor —
montar() reprova sozinho no /exportar. -->
<section class="cartao fase3">
{% if s.pendencias %}
<p class="vermelho">{{ s.pendencias|length }} pendência(s) impedem a exportação.
Corrija abaixo e salve — ou edite o SD.md e envie de novo.</p>
{% else %}
<p class="verde">Tudo o que a carga exige está presente e fecha: datas, semanas,
linha de OS, status e vocabulário. UST, tarifa e valor são calculados pelo
sistema na carga. O JSON sai com a estrutura de sempre.</p>
{% endif %}
<div class="acoes">
<form method="post" action="{{ url_for('exportar') }}">
<button type="submit" class="primario" {{ 'disabled' if not s.pode_exportar }}>
Exportar JSON
</button>
</form>
{% if s.pendencias %}
<button type="submit" form="form-pendencias" class="primario">Salvar correções e conferir de novo</button>
{% endif %}
<a class="botao discreto" href="{{ url_for('salvar_sd_preview') }}">Salvar SD.md…</a>
</div>
</section>
<!-- ================= Pendências =================
Um controle por campo pendente, e nenhum a mais. Os nomes seguem
Pendencia.chave (sd__campo · e{i}__campo · corpo__campo); o servidor só
toca no que vem no POST, então campo fora daqui não é alterado. -->
{% if s.pendencias %}
<form method="post" action="{{ url_for('salvar_revisao') }}" class="formulario" id="form-pendencias">
{% for grupo in formulario %}
<section class="cartao entregavel" id="grupo-{{ loop.index }}">
<h3>{{ grupo.titulo }}</h3>
{% for c in grupo.campos %}
<div class="pendencia">
{% for m in c.mensagens %}
<p class="texto-achado"><span class="tag">PENDENTE</span> {{ m }}</p>
{% endfor %}
{% if c.controle == 'mensagem' %}
<p class="nota limitacao">Este conserto é no <code>SD.md</code>: corrija o arquivo e envie de novo.</p>
{% elif c.controle == 'textarea' %}
<label>{{ c.rotulo }}
<textarea name="{{ c.chave }}" rows="{{ 5 if c.campo == 'objetivo' else 3 }}">{{ c.valor }}</textarea></label>
{% elif c.controle == 'select' %}
<label>{{ c.rotulo }}
<select name="{{ c.chave }}">
<option value="">—</option>
{% for o in c.opcoes %}
<option value="{{ o }}" {{ 'selected' if c.valor|string == o|string }}>{{ o }}</option>
{% endfor %}
</select></label>
{% else %}
<label>{{ c.rotulo }}
{% if c.derivado is not none %}<span class="nota"> — pela regra da carga: <strong>{{ c.derivado }}</strong>; em branco, deriva</span>{% endif %}
<input name="{{ c.chave }}" value="{{ c.valor }}" placeholder="{{ c.placeholder }}"></label>
{% endif %}
</div>
{% endfor %}
</section>
{% endfor %}
</form>
{% endif %}
<!-- ================= Resumo do que vai sair =================
Derivado do payload como ele está. Com pendência, aparecem os buracos
(linha vazia, UST vazia) — é a prévia, não o arquivo. -->
<section class="cartao camada-script">
<h3>O que o JSON vai conter <span class="selo">derivado do SD.md</span></h3>
{% set sd = previa.sd or {} %}
<div class="grade resumo">
<div><span class="nota">Número</span><strong>{{ sd.numero_sequencial }}</strong></div>
<div><span class="nota">Projeto</span><strong>{{ (sd.projeto or {}).codigo }}</strong> {{ (sd.projeto or {}).nome }}</div>
<div><span class="nota">PO responsável</span><strong>{{ (sd.po_responsavel or {}).nome or '—' }}</strong></div>
<div><span class="nota">Status da SD</span><strong>{{ sd.status or '—' }}</strong></div>
<div><span class="nota">Período</span><strong>{{ sd.inicio or '—' }} → {{ sd.fim or '—' }}</strong></div>
<div><span class="nota">Itens</span><strong>{{ (sd.itens_contratuais or [])|join(', ') or '—' }}</strong></div>
<div><span class="nota">Entregáveis</span><strong>{{ (previa.entregaveis or [])|length }}</strong></div>
</div>
<div class="tabela-rolavel">
<table class="entregaveis">
<thead>
<tr><th>n</th><th>Entregável</th><th>Tipo</th><th>Item</th><th>Linha OS</th>
<th>Início</th><th>Fim</th><th>Dias</th><th>Sem.</th><th>Status</th></tr>
</thead>
<tbody>
{% for e in previa.entregaveis or [] %}
<tr>
<td class="num">{{ e.n }}</td>
<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="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>
<td class="num">{{ e.numero_semanas if e.numero_semanas is not none else '—' }}</td>
<td>{{ e.status or '—' }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<p class="nota">Semanas = max(1, ⌈dias ÷ 7⌉) sobre as datas, teto de 28 dias. UST, tarifa e
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>
</section>
{% endblock %}
+43
View File
@@ -0,0 +1,43 @@
{% extends "base.html" %}
{% set estado_atual = s.estado %}
{% set erro = s.ultimo_erro %}
{% block titulo %}Salvar SD.md{% endblock %}
{% block conteudo %}
<section class="cartao">
<h2>Salvar as edições no SD.md</h2>
<p>O frontmatter é regravado em <em>round-trip</em>: comentários, ordem das
chaves e aspas são preservados. Listas reconstruídas pelo formulário
(critérios, artefatos, dependências) perdem comentários internos, se houver.</p>
{% if no_repositorio %}
<div class="faixa alerta">
<strong>Este destino está dentro do repositório.</strong> A prosa gravada é a
da <em>sessão</em>, que veio do arquivo enviado — se o SD.md do repositório
tiver um corpo diferente, ele será substituído. Confira no diff abaixo.
</div>
{% endif %}
<form method="post" action="{{ url_for('salvar_sd') }}">
<label>Caminho de destino
<input name="destino" value="{{ destino }}" size="100"
placeholder="caminho completo do SD.md" required></label>
{% if not destino %}
<p class="nota">Não encontrei no repositório um <code>SD.md</code> cujo campo
<code>sd:</code> case sozinho com o desta sessão. Informe o caminho.</p>
{% endif %}
<h3>Diferenças</h3>
<pre class="diff">{{ diff }}</pre>
<label class="radio">
<input type="checkbox" name="confirmo" value="sim" required>
Confirmo a gravação neste caminho.</label>
<div class="acoes">
<button type="submit" class="primario">Gravar SD.md</button>
<a class="botao discreto" href="{{ url_for('revisar') }}">Voltar sem gravar</a>
</div>
</form>
</section>
{% endblock %}
+23
View File
@@ -0,0 +1,23 @@
{% extends "base.html" %}
{% set estado_atual = 'sem_arquivo' %}
{% block titulo %}Enviar SD.md{% endblock %}
{% block conteudo %}
<section class="cartao">
<h2>Enviar o SD.md</h2>
<p>Selecione o <code>SD.md</code> da Solicitação de Demanda. O arquivo é lido
e conferido contra as regras da carga do sistema de gestão. Se estiver
completo, a próxima tela mostra o resumo e libera o botão
<em>Exportar JSON</em>; se faltar algo, ela abre um formulário só com os
campos a corrigir. Nada é exportado neste passo.</p>
<form method="post" action="{{ url_for('upload') }}" enctype="multipart/form-data">
<input type="file" name="arquivo" accept=".md" required>
<button type="submit" class="primario">Enviar e conferir</button>
</form>
<p class="nota">Somente arquivos <code>.md</code> com frontmatter YAML. O arquivo
enviado não é alterado: as correções ficam na sessão, e só voltam ao disco se
você pedir explicitamente em <em>Salvar SD.md</em>.</p>
</section>
{% endblock %}