feat(documents): suporte a linha condicional (conditional) + catálogo completo

Necessário pra importar os 7 templates reais do legado (fornecidos pelo
usuário via export do OrçaFácil): o Termo de Contratação STFC usa o nó
`conditional` (envelope em volta de uma tableRow, franquia de minutos/0800)
— sem suporte, tanto o backend quanto o editor Tiptap rejeitavam/renderizavam
em branco esse documento específico, mesmo bug já documentado no export do
legado. A condição em si nunca é avaliada nesta leva (fora de escopo) — a
linha sempre passa adiante, comportamento explícito e documentado.

Também adiciona ao variable-catalog.ts as ~25 variáveis que faltavam
(tarifação STFC por banda, 0800, portabilidade, prazo de instalação),
descobertas comparando contra as chaves reais usadas nos 7 templates
importados.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-19 20:26:18 -03:00
parent 1545666f17
commit 3785b32c5f
6 changed files with 215 additions and 6 deletions

View File

@@ -7,11 +7,13 @@
*
* Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
* realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField, marks bold/
* italic/underline/strike/textStyle(fontSize). NÃO implementado (fora de
* escopo desta leva, ver docs/progress.md): nó `conditional` e
* `tableRow.attrs.repeat` (expansão de linha de tabela) — o editor/catálogo
* de variáveis desta leva nunca produz nenhum dos dois.
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField/conditional,
* marks bold/italic/underline/strike/textStyle(fontSize). `conditional`
* (envelope em volta de uma `tableRow`, usado pelo Termo de Contratação
* STFC importado do legado) sempre RENDERIZA sua linha — avaliação real da
* condição contra o contexto é fora de escopo desta leva (ver
* docs/progress.md). NÃO implementado: `tableRow.attrs.repeat` (expansão de
* linha de tabela) — nenhum dos 7 templates importados do legado usa.
*/
export interface TiptapMark {
@@ -117,6 +119,13 @@ function renderBlock(node: TiptapNode, resolveMergeField?: ResolveMergeField): s
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
return `<td${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</td>`;
}
case "conditional":
// Envelope em volta de uma `tableRow` (attrs: field/operator/value) —
// avaliação real da condição contra o contexto (eden.md §3.2,
// `applyConditionals`) é fora de escopo desta leva (ver
// docs/progress.md): a linha sempre passa adiante, igual ao
// comportamento do próprio legado quando não avaliada.
return (node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("");
default:
return "";
}

View File

@@ -101,6 +101,21 @@ export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
{ key: "oferta.franquia_minutos", label: "Franquia de Minutos", category: "Oferta", required: false },
{ key: "oferta.desconto_fidelidade", label: "Desconto por Fidelidade", category: "Oferta", required: false },
{ key: "oferta.total_beneficios", label: "Total de Benefícios (VTB)", category: "Oferta", required: false },
{ key: "oferta.comercializacao_inicio", label: "Início do Prazo de Comercialização", category: "Oferta", required: false },
{ key: "oferta.comercializacao_fim", label: "Fim do Prazo de Comercialização", category: "Oferta", required: false },
// 0800 — sem cadastro próprio, preenchidos manualmente na geração.
{ key: "oferta.inclui_0800", label: "Inclui Número 0800? (Sim/Não)", category: "Oferta", required: false },
{ key: "oferta.numero_0800", label: "Número 0800", category: "Oferta", required: false },
{ key: "oferta.franquia_0800_min", label: "Franquia Mensal de 0800 (min)", category: "Oferta", required: false },
{ key: "oferta.valor_0800_padrao", label: "Valor 0800 — Padrão (por minuto)", category: "Oferta", required: false },
{ key: "oferta.valor_0800_reduzido", label: "Valor 0800 — Reduzido (por minuto)", category: "Oferta", required: false },
// Tabela de tarifação STFC — automática a partir de products.tariff_rates do produto medido da oferta.
...(["lc", "ldn", "vc1", "vc2", "vc3", "ldi"] as const).flatMap((t) => [
{ key: `oferta.tarifa_${t}_normal`, label: `Tarifa ${t.toUpperCase()} — Normal (por minuto)`, category: "Oferta", required: false },
{ key: `oferta.tarifa_${t}_reduzida`, label: `Tarifa ${t.toUpperCase()} — Reduzida (por minuto)`, category: "Oferta", required: false },
]),
// CONTRATO — client_registrations.ixc_contract_number + quotes.contract_period/fidelity_period.
{ key: "contrato.numero", label: "Número do Contrato IXC", category: "Contrato", required: false },
@@ -111,6 +126,7 @@ export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
{ key: "contrato.software_nome", label: "Nome do Software", category: "Contrato", required: false },
{ key: "contrato.numeros_portar", label: "Números a Portar", category: "Contrato", required: false },
{ key: "contrato.quantidade_portar", label: "Quantidade de Números a Portar", category: "Contrato", required: false },
{ key: "contrato.prazo_instalacao", label: "Prazo de Instalação", category: "Contrato", required: false },
// PORTABILIDADE / CESSÃO — sem cadastro próprio, preenchidos manualmente na geração.
{ key: "portabilidade.prestadora_doadora", label: "Prestadora Doadora", category: "Portabilidade", required: false },
@@ -121,6 +137,12 @@ export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
category: "Portabilidade",
required: false,
},
{
key: "portabilidade.titular_cpf_cnpj_doadora",
label: "CNPJ/CPF do Titular na Doadora",
category: "Portabilidade",
required: false,
},
{ key: "cessao.codigo_acesso_cedido", label: "Código(s) de Acesso Cedido(s)", category: "Cessão", required: false },
{ key: "cessao.numeros_contratados", label: "Números Contratados", category: "Cessão", required: false },

View File

@@ -116,6 +116,56 @@ describe("editor-content-renderer", () => {
expect(resolvedHtml).not.toContain("merge-field-chip");
});
it("conditional sempre renderiza sua linha (avaliação real de condição é trabalho futuro)", () => {
const doc = {
type: "doc",
content: [
{
type: "table",
content: [
{
type: "tableRow",
content: [{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Coluna" }] }] }],
},
{
type: "conditional",
attrs: { field: "oferta.franquia_minutos", operator: "exists" },
content: [
{
type: "tableRow",
content: [
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "linha condicional" }] }] },
],
},
],
},
],
},
],
};
const html = renderToHtml(doc);
expect(html).toMatch(/<thead>.*<th><p>Coluna<\/p><\/th>.*<\/thead>/s);
expect(html).toContain("<tr><td><p>linha condicional</p></td></tr>");
expect(html).not.toContain("conditional");
});
it("collectMergeFieldKeys enxerga chaves dentro de um conditional", () => {
const doc = {
type: "doc",
content: [
{
type: "conditional",
attrs: { field: "x" },
content: [
{ type: "tableRow", content: [{ type: "tableCell", content: [{ type: "mergeField", attrs: { key: "a.b" } }] }] },
],
},
],
};
expect(collectMergeFieldKeys(doc)).toEqual(["a.b"]);
});
it("escapa HTML de texto e de valores resolvidos (proteção contra XSS)", () => {
const doc = {
type: "doc",

View File

@@ -13,8 +13,18 @@ import { TableCell } from "@tiptap/extension-table-cell";
import type { VariableCatalogEntry } from "../../../lib/api/documents.js";
import { MergeField } from "./mergeFieldExtension.js";
import { PageBreak } from "./pageBreakExtension.js";
import { ConditionalRow } from "./conditionalRowExtension.js";
import { EditorToolbar } from "./EditorToolbar.js";
/**
* `Table` precisa aceitar `conditional` como filho direto, além de
* `tableRow` — sem isso o Tiptap rejeita o documento inteiro (renderiza em
* branco) sempre que uma tabela contém uma linha condicional, exatamente o
* bug documentado no export dos templates do legado (README dentro de
* `document_templates_export.zip`) e já corrigido lá da mesma forma.
*/
const TableWithConditional = Table.extend({ content: "(tableRow|conditional)+" });
/**
* Editor de rascunho real (Tiptap) — substitui o `<textarea>` de texto puro
* das levas anteriores. `content` é só o valor INICIAL (Tiptap é
@@ -39,12 +49,13 @@ export function DocumentEditor({
FontSize,
TextAlign.configure({ types: ["heading", "paragraph"] }),
Image,
Table.configure({ resizable: false }),
TableWithConditional.configure({ resizable: false }),
TableRow,
TableHeader,
TableCell,
MergeField,
PageBreak,
ConditionalRow,
],
content,
onUpdate: ({ editor: updated }) => onChange(updated.getJSON()),

View File

@@ -0,0 +1,59 @@
import { mergeAttributes, Node } from "@tiptap/core";
/**
* Envolve uma única `tableRow` que só deveria aparecer no documento final se
* uma condição bater contra o contexto (avaliada no backend — ver
* apps/api/src/documents/editor-content-renderer.ts, case 'conditional').
* Porta de `ConditionalRow.jsx` (export dos templates do legado,
* `/home/handix/document_templates_export.zip`) — necessário pra abrir o
* Termo de Contratação STFC, o único dos 7 templates importados que usa
* linhas condicionais (portabilidade/franquia de minutos).
*
* Nesta leva a condição NUNCA é avaliada de verdade (fora de escopo, ver
* docs/progress.md) — a linha sempre aparece no PDF final, igual ao
* comportamento do backend quando `conditional` não é resolvido.
*
* Renderiza como `<tbody style="display:contents">` (sem caixa própria) —
* a `<tr>` de dentro participa do layout da tabela normalmente, só o
* "envelope" de identificação some visualmente no editor.
*/
export const ConditionalRow = Node.create({
name: "conditional",
group: "block",
content: "tableRow",
isolating: true,
addAttributes() {
return {
field: { default: null },
operator: { default: "exists" },
value: { default: null },
};
},
parseHTML() {
return [{ tag: "tbody[data-conditional-field]" }];
},
renderHTML({ node, HTMLAttributes }) {
return [
"tbody",
mergeAttributes(HTMLAttributes, {
style: "display:contents",
"data-conditional-field": node.attrs.field || "",
title: `Linha condicional — sempre visível nesta leva (avaliação real de "${node.attrs.field || "?"}" é trabalho futuro)`,
}),
0,
];
},
addNodeView() {
return ({ node }) => {
const dom = document.createElement("tbody");
dom.style.cssText = "display:contents";
dom.setAttribute("data-conditional-field", node.attrs.field || "");
dom.title = `Linha condicional — sempre visível nesta leva (avaliação real de "${node.attrs.field || "?"}" é trabalho futuro)`;
return { dom, contentDOM: dom };
};
},
});

View File

@@ -913,3 +913,61 @@ Publicado o rascunho e confirmado que a versão publicada aparece corretamente n
"Razão Social"/"CNPJ/CPF" renderizado com o estilo `.merge-field-chip` aplicado). Template de teste
(`qa_test_editor_*`) desativado ao final via `UPDATE document_templates SET active=false` (sem endpoint de
exclusão, mesmo padrão de higiene de dados de teste já seguido nesta sessão).
---
## Importação dos 7 templates reais do legado + suporte a `conditional`
Data: 2026-09-19. Usuário forneceu `/home/handix/document_templates_export.zip` — export real dos 7
templates de documento do OrçaFácil legado (Contrato de Software, Termo de Cessão, Termo de Portabilidade,
Termo de Contratação STFC, Contrato Canal Finder, Contrato Canal Recorrente, Termo de Adesão), cada um com
seu `editor_content` (árvore Tiptap real, não simplificada) e as 4 extensões Tiptap customizadas do
próprio editor legado (`mergeField`, `pageBreak`, `conditional`, `tableRow.repeat`). Pediu pra trazer esses
templates pro EDEN "pra ver como ficou" — validação de ponta a ponta do editor rico construído na leva
anterior contra conteúdo de produção real, não sintético.
**Achado real**: dos 7 templates, 6 usam só o que já era suportado (paragraph/table/mergeField/bold/
textAlign/colspan); o Termo de Contratação STFC usa também o nó `conditional` (envelope em volta de uma
`tableRow`, usado nas linhas de franquia de minutos/0800) — não suportado na leva anterior. Sem essa
extensão, o **editor renderizava em branco** pra esse documento específico — exatamente o mesmo bug já
documentado (e corrigido) no próprio export do legado (`ConditionalRow.jsx`, comentário no README do zip).
Nenhum dos 7 usa `tableRow.attrs.repeat` — não precisou ser implementado.
### Backend
`editor-content-renderer.ts`: novo `case "conditional"` em `renderBlock` — sempre renderiza a linha
(avaliação real da condição contra contexto cru é trabalho futuro, fora de escopo, igual já documentado
pro nó em si). `collectMergeFieldKeys` já enxergava por dentro de qualquer node type genericamente, não
precisou mudar. `variable-catalog.ts`: adicionadas as ~25 entradas que faltavam (tabela de tarifação STFC
por banda/normal-reduzida, 0800, `comercializacao_inicio/fim`, `contrato.prazo_instalacao`,
`portabilidade.titular_cpf_cnpj_doadora`) — descobertas comparando as chaves realmente usadas nos 7
templates contra o catálogo já portado, todas historicamente do `variableRegistry.js` original.
### Frontend
Novo `conditionalRowExtension.ts` (porta de `ConditionalRow.jsx` do export) + `Table.extend({ content:
"(tableRow|conditional)+" })` em `DocumentEditor.tsx` — sem essa extensão de schema o Tiptap rejeita
qualquer documento com `conditional` como filho direto de `table`, mesmo bug do parágrafo acima.
### Importação
Script único (`scratchpad`, não versionado — é carga de dados, não feature) reaproveitando os endpoints já
existentes (create template → create draft → update draft com o `editor_content` real → publish) pra cada
um dos 7 JSONs, exatamente como um humano faria pela UI — nenhum endpoint de "importar" novo foi criado.
### Verificação
`pnpm turbo run typecheck build` 28/28. Suíte 125/125 (123→125: 2 novos casos de `conditional`). Os 7
templates reais importados e publicados com sucesso contra o ambiente docker local. Testado ao vivo: tela
de overview do Termo de Contratação STFC renderiza o contrato completo (3 páginas) com a tabela de
tarifação (LC/LDN/VC1/VC2/VC3/LDI × Normal/Reduzido) e as linhas condicionais de franquia/0800 no lugar
certo; o editor rico abre o MESMO documento sem tela em branco, com as 53 variáveis usadas listadas
corretamente no painel lateral (com badges "obrigatório"). Contrato de Software (o maior, 162KB de JSON)
também verificado: abre no editor sem erro de console/página, gera pré-visualização em PDF real sem erro.
PDF do STFC gerado via curl direto (Chromium real) e conferido via `pdftotext`: 3 páginas, texto
`[oferta.franquia_minutos]`/`[oferta.inclui_0800]`/`[oferta.numero_0800]`/tarifas em `[chave]` no lugar
certo do documento — confirma que o `conditional` nunca é removido, só passa adiante sem avaliação real
(comportamento documentado, não um bug).
### Achado colateral, não corrigido (fora de escopo)
A tela de listagem de templates estava com ~145 registros `qa_test_*` acumulados de suítes de teste
anteriores desta sessão — desativados via `UPDATE document_templates SET active=false` (mesmo padrão já
usado antes). Restaram ~9 templates de teste manual mais antigos (`client_test_contract`, `live_test_*`,
`smoke_*`, `teste_web_final` etc.) que não seguem o prefixo `qa_test_` automatizado — não mexidos, por
poderem ser exploração manual do próprio usuário.