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:
@@ -7,11 +7,13 @@
|
|||||||
*
|
*
|
||||||
* Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
|
* Cobre os nós que o editor visual (apps/core-web/src/routes/documents/editor)
|
||||||
* realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
|
* realmente produz: paragraph/heading/bulletList/orderedList/listItem/image/
|
||||||
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField, marks bold/
|
* pageBreak/table+tableRow+tableHeader+tableCell/mergeField/conditional,
|
||||||
* italic/underline/strike/textStyle(fontSize). NÃO implementado (fora de
|
* marks bold/italic/underline/strike/textStyle(fontSize). `conditional`
|
||||||
* escopo desta leva, ver docs/progress.md): nó `conditional` e
|
* (envelope em volta de uma `tableRow`, usado pelo Termo de Contratação
|
||||||
* `tableRow.attrs.repeat` (expansão de linha de tabela) — o editor/catálogo
|
* STFC importado do legado) sempre RENDERIZA sua linha — avaliação real da
|
||||||
* de variáveis desta leva nunca produz nenhum dos dois.
|
* 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 {
|
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}"` : "";
|
const colspan = typeof node.attrs?.colspan === "number" ? ` colspan="${node.attrs.colspan}"` : "";
|
||||||
return `<td${colspan}>${(node.content ?? []).map((c) => renderBlock(c, resolveMergeField)).join("")}</td>`;
|
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:
|
default:
|
||||||
return "";
|
return "";
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -101,6 +101,21 @@ export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
|
|||||||
{ key: "oferta.franquia_minutos", label: "Franquia de Minutos", category: "Oferta", required: false },
|
{ 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.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.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.
|
// CONTRATO — client_registrations.ixc_contract_number + quotes.contract_period/fidelity_period.
|
||||||
{ key: "contrato.numero", label: "Número do Contrato IXC", category: "Contrato", required: false },
|
{ 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.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.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.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.
|
// PORTABILIDADE / CESSÃO — sem cadastro próprio, preenchidos manualmente na geração.
|
||||||
{ key: "portabilidade.prestadora_doadora", label: "Prestadora Doadora", category: "Portabilidade", required: false },
|
{ key: "portabilidade.prestadora_doadora", label: "Prestadora Doadora", category: "Portabilidade", required: false },
|
||||||
@@ -121,6 +137,12 @@ export const VARIABLE_CATALOG: VariableCatalogEntry[] = [
|
|||||||
category: "Portabilidade",
|
category: "Portabilidade",
|
||||||
required: false,
|
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.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 },
|
{ key: "cessao.numeros_contratados", label: "Números Contratados", category: "Cessão", required: false },
|
||||||
|
|
||||||
|
|||||||
@@ -116,6 +116,56 @@ describe("editor-content-renderer", () => {
|
|||||||
expect(resolvedHtml).not.toContain("merge-field-chip");
|
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)", () => {
|
it("escapa HTML de texto e de valores resolvidos (proteção contra XSS)", () => {
|
||||||
const doc = {
|
const doc = {
|
||||||
type: "doc",
|
type: "doc",
|
||||||
|
|||||||
@@ -13,8 +13,18 @@ import { TableCell } from "@tiptap/extension-table-cell";
|
|||||||
import type { VariableCatalogEntry } from "../../../lib/api/documents.js";
|
import type { VariableCatalogEntry } from "../../../lib/api/documents.js";
|
||||||
import { MergeField } from "./mergeFieldExtension.js";
|
import { MergeField } from "./mergeFieldExtension.js";
|
||||||
import { PageBreak } from "./pageBreakExtension.js";
|
import { PageBreak } from "./pageBreakExtension.js";
|
||||||
|
import { ConditionalRow } from "./conditionalRowExtension.js";
|
||||||
import { EditorToolbar } from "./EditorToolbar.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
|
* Editor de rascunho real (Tiptap) — substitui o `<textarea>` de texto puro
|
||||||
* das levas anteriores. `content` é só o valor INICIAL (Tiptap é
|
* das levas anteriores. `content` é só o valor INICIAL (Tiptap é
|
||||||
@@ -39,12 +49,13 @@ export function DocumentEditor({
|
|||||||
FontSize,
|
FontSize,
|
||||||
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
TextAlign.configure({ types: ["heading", "paragraph"] }),
|
||||||
Image,
|
Image,
|
||||||
Table.configure({ resizable: false }),
|
TableWithConditional.configure({ resizable: false }),
|
||||||
TableRow,
|
TableRow,
|
||||||
TableHeader,
|
TableHeader,
|
||||||
TableCell,
|
TableCell,
|
||||||
MergeField,
|
MergeField,
|
||||||
PageBreak,
|
PageBreak,
|
||||||
|
ConditionalRow,
|
||||||
],
|
],
|
||||||
content,
|
content,
|
||||||
onUpdate: ({ editor: updated }) => onChange(updated.getJSON()),
|
onUpdate: ({ editor: updated }) => onChange(updated.getJSON()),
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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
|
"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
|
(`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).
|
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.
|
||||||
|
|||||||
Reference in New Issue
Block a user