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 };
};
},
});