feat(quotes): anexo de conformidade ANATEL/RGC no PDF de oferta

Porta o anexo regulatório do legado (Resolução Anatel 765/2023, RGC,
recorte STFC) com fidelidade total ao conteúdo legal, incluindo a
seção de tarifação — em vez do html2canvas+jsPDF client-side do
legado, usa o Chromium server-side já construído para Documents.

Schema: products ganha service_type/metered/tariff_rates/has_ldi
(migration 1788525270647) — campos que a migration original de
products tinha deliberadamente deferido. Snapshot no item da oferta
na criação (nunca referência viva ao produto). Novo pacote
compartilhado packages/domain-shared/src/telecom para o catálogo de
tipos e legendas de tarifação.

Endpoint GET /v1/quotes/:id/anatel-pdf (400 sem item STFC), sempre
renderizado ao vivo (nunca cacheado, já que o conteúdo deriva de
updated_at). Frontend: campos de produto/oferta necessários +
"Baixar PDF ANATEL" na tela de detalhe da oferta. Inclui também o
botão de download de PDF em Documentos (endpoint já existia, faltava
o link na UI).

Verificado ao vivo através do container real: PDF gerado com
Chromium de verdade, texto extraído confere byte a byte com os
valores computados (multa, datas, tarifas, resumo financeiro).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx
This commit is contained in:
2026-09-04 10:33:33 -03:00
parent 29430d0d96
commit ccac936372
26 changed files with 1321 additions and 20 deletions

View File

@@ -1,4 +1,7 @@
import { IsBoolean, IsNumber, IsOptional, IsString, Min, MinLength } from "class-validator"; import { IsBoolean, IsIn, IsNumber, IsOptional, IsString, Min, MinLength, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
import { SERVICE_TYPES, type ServiceType } from "@eden/domain-shared";
import { TariffRatesDto } from "./tariff-rate.dto.js";
export class CreateProductDto { export class CreateProductDto {
@IsString() @IsString()
@@ -33,4 +36,15 @@ export class CreateProductDto {
@IsOptional() @IsNumber() @Min(0) price36?: number; @IsOptional() @IsNumber() @Min(0) price36?: number;
@IsOptional() @IsNumber() @Min(0) price48?: number; @IsOptional() @IsNumber() @Min(0) price48?: number;
@IsOptional() @IsNumber() @Min(0) implUnitPrice?: number; @IsOptional() @IsNumber() @Min(0) implUnitPrice?: number;
// Recorte regulatório RGC (Resolução Anatel 765/2023, ver
// packages/domain-shared/src/telecom/service-types.ts) — usado pelo
// anexo de conformidade ANATEL do PDF de oferta (apps/api/src/quotes/
// anatel-offer-template.ts). Snapshot no item da oferta no momento da
// criação, não referência viva ao produto.
@IsOptional() @IsIn(SERVICE_TYPES) serviceType?: ServiceType;
@IsOptional() @IsBoolean() metered?: boolean;
@IsOptional() @IsNumber() @Min(0) minutesAllowance?: number;
@IsOptional() @ValidateNested() @Type(() => TariffRatesDto) tariffRates?: TariffRatesDto;
@IsOptional() @IsBoolean() hasLdi?: boolean;
} }

View File

@@ -0,0 +1,17 @@
import { IsNumber, IsOptional, Min, ValidateNested } from "class-validator";
import { Type } from "class-transformer";
export class TariffRateDto {
@IsNumber() @Min(0) normal!: number;
@IsNumber() @Min(0) reduced!: number;
}
/** One optional key per TARIFF_TYPES entry (LC/LDN/VC1/VC2/VC3/LDI) — validated by TariffRateDto below, not decorated individually since the set is derived from the shared catalog. */
export class TariffRatesDto {
@IsOptional() @ValidateNested() @Type(() => TariffRateDto) LC?: TariffRateDto;
@IsOptional() @ValidateNested() @Type(() => TariffRateDto) LDN?: TariffRateDto;
@IsOptional() @ValidateNested() @Type(() => TariffRateDto) VC1?: TariffRateDto;
@IsOptional() @ValidateNested() @Type(() => TariffRateDto) VC2?: TariffRateDto;
@IsOptional() @ValidateNested() @Type(() => TariffRateDto) VC3?: TariffRateDto;
@IsOptional() @ValidateNested() @Type(() => TariffRateDto) LDI?: TariffRateDto;
}

View File

@@ -1,8 +1,15 @@
import { Injectable, NotFoundException } from "@nestjs/common"; import { Injectable, NotFoundException } from "@nestjs/common";
import { auditRepo, productsRepo, type Product } from "@eden/database"; import { auditRepo, productsRepo, type Product } from "@eden/database";
import { emptyTariffRates, type TariffRates } from "@eden/domain-shared";
import type { CreateProductDto } from "./dto/create-product.dto.js"; import type { CreateProductDto } from "./dto/create-product.dto.js";
import type { UpdateProductDto } from "./dto/update-product.dto.js"; import type { UpdateProductDto } from "./dto/update-product.dto.js";
/** DTO fields are individually optional (partial override); persisted tariff_rates always carries all 6 keys — mirrors the legacy's normalizeTariffRates(), never a partial object at rest. */
function normalizeTariffRates(partial: Partial<TariffRates> | undefined): TariffRates | undefined {
if (!partial) return undefined;
return { ...emptyTariffRates(), ...partial };
}
@Injectable() @Injectable()
export class ProductsService { export class ProductsService {
list(includeInactive: boolean): Promise<Product[]> { list(includeInactive: boolean): Promise<Product[]> {
@@ -36,6 +43,11 @@ export class ProductsService {
price36: dto.price36, price36: dto.price36,
price48: dto.price48, price48: dto.price48,
implUnitPrice: dto.implUnitPrice, implUnitPrice: dto.implUnitPrice,
serviceType: dto.serviceType,
metered: dto.metered,
minutesAllowance: dto.minutesAllowance,
tariffRates: normalizeTariffRates(dto.tariffRates),
hasLdi: dto.hasLdi,
createdBy: actorId, createdBy: actorId,
}); });
@@ -73,6 +85,11 @@ export class ProductsService {
price36: dto.price36, price36: dto.price36,
price48: dto.price48, price48: dto.price48,
implUnitPrice: dto.implUnitPrice, implUnitPrice: dto.implUnitPrice,
serviceType: dto.serviceType,
metered: dto.metered,
minutesAllowance: dto.minutesAllowance,
tariffRates: normalizeTariffRates(dto.tariffRates),
hasLdi: dto.hasLdi,
}); });
if (!product) throw new NotFoundException("Produto não encontrado"); if (!product) throw new NotFoundException("Produto não encontrado");

View File

@@ -0,0 +1,517 @@
/**
* Anexo de conformidade RGC (Regulamento Geral de Direitos do Consumidor
* de Serviços de Telecomunicações, Resolução Anatel nº 765/2023), recorte
* STFC — campos exigidos pelo art. 21 §3º (I a X) e arts. 26, 27, 36 a 39.
* Ported com fidelidade do legado (BackofficeAnatelPDFTemplate.jsx),
* porém como PDF autônomo (renderizado via Chromium server-side, ver
* packages/integrations/src/pdf/renderer.ts) em vez de anexo capturado
* client-side com html2canvas — e SEM o split automático software/
* não-software do legado (splitQuoteByServiceType.js): esta leva sempre
* gera um único documento cobrindo todos os itens da oferta. Se o negócio
* precisar do split (contratos separados para itens "Serviço software"),
* isso é trabalho futuro, não implementado aqui.
*
* Termos usados seguem o art. 3º do RGC: Prazo de Comercialização (inciso
* XI) é a janela em que a oferta fica disponível para contratação — NÃO é
* "Prazo de Oferta" (esse termo não existe no RGC). Prazo de Vigência
* (inciso XIII) é a duração do contrato a partir da contratação —
* corresponde ao período de fidelidade efetivo desta oferta.
*
* Campos sem fonte de dado institucional/jurídica confirmada (Handix ainda
* não definiu) usam o placeholder [A DEFINIR] — nunca um valor inventado.
*/
import type { Quote, QuoteItem, Reseller } from "@eden/database";
import {
formatCNPJ,
formatCPF,
getTariffTypesToShow,
TARIFF_DESCRIPTIONS,
TARIFF_EXTRA_NOTES,
} from "@eden/domain-shared";
import { computeQuotePricing, computeSavings, resolveEffectiveFidelity } from "./pricing.js";
export function hasStfcItem(items: QuoteItem[]): boolean {
return items.some((item) => item.serviceType === "STFC");
}
export interface ClientDocument {
cpf: string | null;
cnpj: string | null;
}
export interface AnatelOfferPdfInput {
quote: Quote;
reseller: Reseller;
clientDocument: ClientDocument;
}
const CANAIS_ATENDIMENTO = "(48) 3420-0969 ou 0800 048 0969";
export function buildAnatelOfferHtml(input: AnatelOfferPdfInput): string {
const { quote, reseller, clientDocument } = input;
const items = quote.items;
const stfc = hasStfcItem(items);
const pricing = computeQuotePricing({
items,
contractPeriod: quote.contract_period,
fidelityPeriod: quote.fidelity_period,
implGeral: Number(quote.impl_geral),
proposedMonthlyTotal: quote.proposed_monthly_total != null ? Number(quote.proposed_monthly_total) : null,
});
const fidelityPeriod = resolveEffectiveFidelity(quote.contract_period, quote.fidelity_period, quote.approval_status);
const savings = computeSavings(
items,
quote.contract_period,
fidelityPeriod,
pricing.monthlyTotal,
quote.proposed_monthly_total != null ? Number(quote.proposed_monthly_total) : null,
);
const updatedDate = quote.updated_at;
const comercializacaoInicio = formatDateBR(updatedDate);
const comercializacaoFim = formatDateBR(addDays(updatedDate, 10));
const nomeOferta = fidelityPeriod === 0 ? "Oferta sem fidelidade" : `Oferta com fidelidade de ${fidelityPeriod} meses`;
const codigoIdentificacao = quote.quote_number ? escapeHtml(quote.quote_number) : placeholder("ID");
const clientLabel = quote.client_company ? quote.client_company : quote.client_name;
const clientDoc = clientDocument.cnpj
? formatCNPJ(clientDocument.cnpj)
: clientDocument.cpf
? formatCPF(clientDocument.cpf)
: placeholder("CPF/CNPJ");
const meteredItems = items.filter((item) => item.metered);
return `
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<style>${css()}</style>
</head>
<body>
<div class="page">
${renderHeader(quote)}
${renderClientBlock(clientLabel, clientDoc, quote)}
${renderPlanBox(quote, fidelityPeriod)}
${renderSavingsBox(savings)}
${renderItemsTable(items, pricing)}
${stfc ? `<p class="tech-note">A tecnologia utilizada para entrega das chamadas do serviço STFC ofertado é VoIP.</p>` : ""}
${meteredItems.length > 0 ? renderTariffSection(meteredItems) : ""}
${pricing.hasSpecialPrice ? renderSpecialConditionBox(quote, pricing) : ""}
${pricing.totalImpl > 0 ? renderImplementationBox(pricing, quote) : ""}
<div class="two-col">
${stfc ? renderRgcBox({ codigoIdentificacao, nomeOferta, comercializacaoInicio, comercializacaoFim, fidelityPeriod, savings }) : "<div></div>"}
${renderFinancialSummaryBox({ quote, reseller, pricing, savings, nomeOferta })}
</div>
${quote.notes ? `<div class="notes"><strong>Observações:</strong> ${escapeHtml(quote.notes)}</div>` : ""}
${renderSignatures(quote, reseller)}
${renderFooter(comercializacaoInicio, comercializacaoFim)}
</div>
</body>
</html>`;
}
function renderHeader(quote: Quote): string {
const today = formatDateBR(new Date());
return `
<div class="header">
<div>
<div class="brand">Handix do Brasil</div>
<div class="brand-sub">0800 048 0969</div>
</div>
<div class="header-right">
<div class="oferta-title">OFERTA</div>
<div>#${escapeHtml(quote.quote_number)}</div>
<div>${today}</div>
</div>
</div>`;
}
function renderClientBlock(clientLabel: string, clientDoc: string, quote: Quote): string {
return `
<div class="section client-block">
<div class="section-label">Cliente</div>
<div class="client-name">${escapeHtml(clientLabel)}</div>
<div>${clientDoc}</div>
${quote.client_email ? `<div>${escapeHtml(quote.client_email)}</div>` : ""}
<div>${escapeHtml(quote.client_phone)}</div>
</div>`;
}
function renderPlanBox(quote: Quote, fidelityPeriod: number): string {
const periodLabel = quote.contract_period > 0 ? `${quote.contract_period} meses` : "S/F (Sem Fidelidade)";
const reduced = quote.fidelity_period != null && quote.approval_status === "approved";
return `
<div class="box plan-box">
<div class="box-title">Plano contratado</div>
<div>${periodLabel}</div>
${reduced ? `<span class="badge badge-info">Fidelidade reduzida: ${fidelityPeriod} meses (aprovada)</span>` : ""}
</div>`;
}
function renderSavingsBox(savings: ReturnType<typeof computeSavings>): string {
return `
<div class="box savings-box">
<div class="box-title">Economia Mensal</div>
<div class="savings-value">${money(savings.totalEconomiaMensal)} /mês</div>
</div>`;
}
function renderItemsTable(items: QuoteItem[], pricing: ReturnType<typeof computeQuotePricing>): string {
const showSpecial = pricing.hasSpecialPrice;
const groups = groupByServiceType(items);
const groupTables = Array.from(groups.entries())
.map(([serviceType, groupItems]) => {
const rows = groupItems
.map((item) => renderItemRow(item, pricing, showSpecial))
.join("");
const subtotal = round2(groupItems.reduce((sum, item) => sum + item.total, 0));
return `
<table class="items-table">
<caption>${escapeHtml(serviceType)}</caption>
<thead>
<tr>
<th>Item</th><th>Qtd</th><th>S/F Unit.</th><th>Preço Unit.</th>
<th>Desc.Fid./un</th><th>Tot.Desc.Fid.</th>
${showSpecial ? "<th>Desc.Esp./un</th><th>Tot.Desc.Esp.</th>" : ""}
<th>Tot.Desc.</th>
${showSpecial ? "<th>Preço Final/un</th>" : ""}
<th>Total Contr.</th>
${showSpecial ? "<th>Total Final</th>" : ""}
</tr>
</thead>
<tbody>${rows}</tbody>
<tfoot><tr><td colspan="${showSpecial ? 11 : 8}">Subtotal: ${money(subtotal)}</td></tr></tfoot>
</table>`;
})
.join("");
const total = round2(items.reduce((sum, item) => sum + item.total, 0));
return `
<div class="section">
<div class="section-label">Detalhamento de Itens e Descontos</div>
${groupTables}
<div class="grand-total">Total Geral da Oferta: ${money(total)}</div>
</div>`;
}
function renderItemRow(item: QuoteItem, pricing: ReturnType<typeof computeQuotePricing>, showSpecial: boolean): string {
const semFidUnit = item.price0 ?? item.unitPrice;
const descFidUnit = round2(semFidUnit - item.unitPrice);
const totDescFid = round2(descFidUnit * item.quantity);
let specialCells = "";
let totDesc = totDescFid;
let finalUnit = item.unitPrice;
let totalFinal = item.total;
if (showSpecial && pricing.monthlyTotal > 0) {
const itemSpecialDiscTotal = round2((item.total / pricing.monthlyTotal) * pricing.diffMonthly);
const specialUnit = round2(itemSpecialDiscTotal / item.quantity);
totDesc = round2(totDescFid + itemSpecialDiscTotal);
finalUnit = round2(item.unitPrice - specialUnit);
totalFinal = round2(item.total - itemSpecialDiscTotal);
specialCells = `<td>${money(specialUnit)}</td><td>${money(itemSpecialDiscTotal)}</td>`;
}
return `
<tr>
<td>${escapeHtml(item.productName)}</td>
<td>${item.quantity}</td>
<td>${money(semFidUnit)}</td>
<td>${money(item.unitPrice)}</td>
<td>${money(descFidUnit)}</td>
<td>${money(totDescFid)}</td>
${specialCells}
<td>${money(totDesc)}</td>
${showSpecial ? `<td>${money(finalUnit)}</td>` : ""}
<td>${money(item.total)}</td>
${showSpecial ? `<td>${money(totalFinal)}</td>` : ""}
</tr>`;
}
function renderTariffSection(meteredItems: QuoteItem[]): string {
const tables = meteredItems
.map((item) => {
const types = getTariffTypesToShow(item.hasLdi);
const rows = types
.map((type) => {
const rate = item.tariffRates[type];
return `<tr><td>${type}</td><td>${money(rate.normal)}</td><td>${money(rate.reduced)}</td></tr>`;
})
.join("");
const legend = types.map((type) => `<li><strong>${type}:</strong> ${TARIFF_DESCRIPTIONS[type]}</li>`).join("");
const franquia =
item.minutesAllowance != null ? `<div>Franquia de minutos: ${item.minutesAllowance}</div>` : "";
return `
<div class="tariff-block">
<div class="tariff-item-name">${escapeHtml(item.productName)}</div>
${franquia}
<table class="tariff-table">
<caption>TABELA DE TARIFAÇÃO (valor por minuto)</caption>
<thead><tr><th>Tipo</th><th>Normal</th><th>Reduzido</th></tr></thead>
<tbody>${rows}</tbody>
</table>
<ul class="tariff-legend">${legend}</ul>
</div>`;
})
.join("");
const notes = TARIFF_EXTRA_NOTES.map((note) => `<li>${escapeHtml(note)}</li>`).join("");
return `
<div class="section">
<div class="section-label">Tarifação</div>
${tables}
<ul class="tariff-notes">${notes}</ul>
</div>`;
}
function renderSpecialConditionBox(quote: Quote, pricing: ReturnType<typeof computeQuotePricing>): string {
let statusLabel: string;
let statusClass: string;
if (pricing.isMarkup) {
statusLabel = "ACRÉSCIMO APLICADO";
statusClass = "badge-warning";
} else if (quote.approval_status === "approved") {
statusLabel = "APROVADA";
statusClass = "badge-success";
} else if (quote.approval_status === "rejected") {
statusLabel = "RECUSADA";
statusClass = "badge-danger";
} else {
statusLabel = "AGUARDANDO APROVAÇÃO";
statusClass = "badge-warning";
}
return `
<div class="box special-condition-box">
<div class="box-title">Condição Especial <span class="badge ${statusClass}">${statusLabel}</span></div>
<div>Preço original: <span class="struck">${money(pricing.monthlyTotal)}</span></div>
<div>${pricing.isMarkup ? "Acréscimo" : "Desconto"}: ${money(Math.abs(pricing.diffMonthly))}</div>
<div>Preço efetivo: ${money(pricing.effectiveMonthly)}</div>
${quote.approval_notes ? `<div>Observação da aprovação: ${escapeHtml(quote.approval_notes)}</div>` : ""}
</div>`;
}
function renderImplementationBox(pricing: ReturnType<typeof computeQuotePricing>, quote: Quote): string {
return `
<div class="box">
<div class="box-title">Total Implantação (único)</div>
<div>${money(pricing.totalImpl)} — condição: ${escapeHtml(quote.impl_payment_condition)}</div>
</div>`;
}
function renderRgcBox(fields: {
codigoIdentificacao: string;
nomeOferta: string;
comercializacaoInicio: string;
comercializacaoFim: string;
fidelityPeriod: number;
savings: ReturnType<typeof computeSavings>;
}): string {
const { codigoIdentificacao, nomeOferta, comercializacaoInicio, comercializacaoFim, fidelityPeriod, savings } = fields;
const vigenciaText =
fidelityPeriod > 0
? `${fidelityPeriod} meses a partir da contratação`
: "Indeterminado (mín. 12 meses garantidos — art. 27, §único)";
const permanenciaText = fidelityPeriod > 0 ? `${fidelityPeriod} meses` : "Não há (oferta sem fidelidade)";
const multaText =
fidelityPeriod > 0
? `Proporcional ao tempo restante, limitada a ${money(savings.beneficioTotalFidelidade)} (benefício concedido)`
: "Não aplicável";
const dataBaseReajuste = `${formatMonthYearBR(new Date())} (última alteração da oferta)`;
const rows: Array<[string, string]> = [
["Código de Identificação Único", codigoIdentificacao],
["Nome Comercial da Oferta", escapeHtml(nomeOferta)],
["Prazo de Comercialização", `${comercializacaoInicio} a ${comercializacaoFim}`],
["Prazo de Vigência", vigenciaText],
["Prazo de Permanência", permanenciaText],
["Multa por Rescisão Antecipada", multaText],
["Critério de Reajuste", "Intervalo mínimo de 12 meses (art. 39)"],
["Data-base de Reajuste", dataBaseReajuste],
["Forma de Pagamento", "Pós-paga"],
["Área de Abrangência", "Nacional"],
["Canais de Atendimento", CANAIS_ATENDIMENTO],
];
return `
<div class="box rgc-box">
<div class="box-title">Conformidade RGC — Resolução Anatel nº 765/2023 (recorte STFC)</div>
<table class="kv-table">
${rows.map(([label, value]) => `<tr><th>${label}</th><td>${value}</td></tr>`).join("")}
</table>
</div>`;
}
function renderFinancialSummaryBox(params: {
quote: Quote;
reseller: Reseller;
pricing: ReturnType<typeof computeQuotePricing>;
savings: ReturnType<typeof computeSavings>;
nomeOferta: string;
}): string {
const { quote, reseller, pricing, savings, nomeOferta } = params;
const rows: Array<[string, string]> = [
["Total Mensal s/ fidelidade", money(pricing.monthlyTotal)],
["Desconto por fidelidade", money(savings.totalDescontoMensal)],
];
if (pricing.hasSpecialPrice) {
rows.push([pricing.isMarkup ? "Acréscimo especial" : "Desconto especial", money(Math.abs(pricing.diffMonthly))]);
}
rows.push(
["Total Mensal Contratado", money(pricing.effectiveMonthly)],
["Total Implantação", money(pricing.totalImpl)],
);
if (pricing.totalImpl > 0) {
rows.push(["Cond. Pagamento Implantação", escapeHtml(quote.impl_payment_condition)]);
}
rows.push(
["Nome da Oferta", escapeHtml(nomeOferta)],
["Abrangência Geográfica", "Nacional"],
["Revenda", escapeHtml(reseller.name)],
["Total do Contrato", money(Number(quote.contract_total))],
);
if (savings.economiaTotalContrato > 0) {
rows.push(["Economia total no contrato", money(savings.economiaTotalContrato)]);
}
return `
<div class="box financial-box">
<div class="box-title">Resumo Financeiro</div>
<table class="kv-table">
${rows.map(([label, value]) => `<tr><th>${label}</th><td>${value}</td></tr>`).join("")}
</table>
</div>`;
}
function renderSignatures(quote: Quote, reseller: Reseller): string {
const salesRep = quote.sales_rep_name ? escapeHtml(quote.sales_rep_name) : "Responsável Comercial";
return `
<div class="signatures">
<div class="signature">
<div class="signature-line"></div>
<div>${salesRep}</div>
<div>${escapeHtml(reseller.name)}</div>
<div class="small">Revendedor autorizado HANDIX</div>
</div>
<div class="signature">
<div class="signature-line"></div>
<div>${escapeHtml(quote.client_name)}</div>
<div class="small">Cliente</div>
</div>
</div>`;
}
function renderFooter(comercializacaoInicio: string, comercializacaoFim: string): string {
return `
<div class="footer">
<div>Prazo de Comercialização: ${comercializacaoInicio} a ${comercializacaoFim}</div>
<div>Handix do Brasil · 0800 048 0969</div>
</div>`;
}
function groupByServiceType(items: QuoteItem[]): Map<string, QuoteItem[]> {
const map = new Map<string, QuoteItem[]>();
for (const item of items) {
const key = item.serviceType ?? "Outros";
const bucket = map.get(key);
if (bucket) bucket.push(item);
else map.set(key, [item]);
}
return map;
}
function placeholder(label?: string): string {
const text = label ? `[A DEFINIR: ${label}]` : "[A DEFINIR]";
return `<span class="placeholder">${escapeHtml(text)}</span>`;
}
function money(value: number): string {
return `R$ ${value.toFixed(2).replace(".", ",")}`;
}
function round2(value: number): number {
return Math.round((value + Number.EPSILON) * 100) / 100;
}
function addDays(date: Date, days: number): Date {
return new Date(date.getTime() + days * 24 * 60 * 60 * 1000);
}
function formatDateBR(date: Date): string {
return new Intl.DateTimeFormat("pt-BR", { day: "2-digit", month: "2-digit", year: "numeric", timeZone: "UTC" }).format(
date,
);
}
function formatMonthYearBR(date: Date): string {
return new Intl.DateTimeFormat("pt-BR", { month: "2-digit", year: "numeric", timeZone: "UTC" }).format(date);
}
function escapeHtml(value: string): string {
return value
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;")
.replace(/'/g, "&#39;");
}
// Font stack has no Google Fonts import deliberately — the renderer blocks
// every outbound request the page makes (see packages/integrations/src/
// pdf/renderer.ts), so an @import here would just silently fail to load.
function css(): string {
return `
@page { size: A4 portrait; margin: 12mm; }
* { box-sizing: border-box; }
body { font-family: 'Segoe UI', Arial, sans-serif; color: #2F3A4B; font-size: 10px; margin: 0; }
.page { width: 100%; }
.header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #0F766E; padding-bottom: 8px; margin-bottom: 10px; }
.brand { font-size: 16px; font-weight: 700; color: #0F766E; }
.brand-sub { color: #67707D; }
.header-right { text-align: right; }
.oferta-title { font-size: 14px; font-weight: 700; letter-spacing: 1px; }
.section { margin-bottom: 10px; }
.section-label { font-weight: 700; color: #0F766E; text-transform: uppercase; font-size: 9px; margin-bottom: 4px; }
.client-block .client-name { font-weight: 700; font-size: 12px; }
.box { border: 1px solid #E8E9EC; border-radius: 6px; padding: 8px 10px; margin-bottom: 10px; }
.box-title { font-weight: 700; margin-bottom: 6px; display: flex; align-items: center; gap: 6px; }
.plan-box, .savings-box { display: inline-block; width: 48%; vertical-align: top; }
.savings-box { margin-left: 2%; }
.savings-value { font-size: 14px; font-weight: 700; color: #059669; }
table { width: 100%; border-collapse: collapse; margin-bottom: 4px; }
.items-table caption, .tariff-table caption { text-align: left; font-weight: 700; padding: 4px 0; }
.items-table th, .items-table td, .tariff-table th, .tariff-table td { border: 1px solid #E8E9EC; padding: 3px 5px; text-align: right; font-size: 8.5px; }
.items-table th:first-child, .items-table td:first-child, .tariff-table th:first-child, .tariff-table td:first-child { text-align: left; }
.items-table tfoot td { text-align: right; font-weight: 700; background: #F8F9FA; }
.grand-total { text-align: right; font-weight: 700; font-size: 11px; margin-top: 4px; }
.tech-note { font-style: italic; color: #67707D; }
.tariff-block { margin-bottom: 8px; }
.tariff-item-name { font-weight: 700; }
.tariff-legend, .tariff-notes { font-size: 8px; color: #67707D; padding-left: 14px; margin: 4px 0; }
.special-condition-box .struck { text-decoration: line-through; color: #67707D; }
.badge { display: inline-block; padding: 1px 6px; border-radius: 4px; font-size: 8px; font-weight: 700; }
.badge-info { background: #DCEFFC; color: #0B91CD; }
.badge-success { background: #DAECE2; color: #059669; }
.badge-warning { background: #FAE7D9; color: #D97706; }
.badge-danger { background: #F8D9D5; color: #B91C1C; }
.two-col { display: flex; gap: 10px; margin-bottom: 10px; }
.two-col > div { flex: 1; }
.kv-table th { text-align: left; font-weight: 600; color: #67707D; width: 45%; padding: 2px 4px; border: none; }
.kv-table td { text-align: left; padding: 2px 4px; border: none; }
.placeholder { background: #fef3c7; padding: 1px 4px; border-radius: 3px; font-weight: 600; }
.notes { border-top: 1px solid #E8E9EC; padding-top: 6px; margin-bottom: 10px; }
.signatures { display: flex; justify-content: space-between; margin-top: 24px; }
.signature { width: 45%; text-align: center; }
.signature-line { border-top: 1px solid #2F3A4B; margin-bottom: 4px; }
.signature .small { color: #67707D; font-size: 8px; }
.footer { display: flex; justify-content: space-between; border-top: 1px solid #E8E9EC; padding-top: 6px; margin-top: 10px; color: #67707D; font-size: 8px; }
`;
}

View File

@@ -50,4 +50,6 @@ export class CreateQuoteDto {
@IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string; @IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string;
@IsOptional() @IsString() notes?: string; @IsOptional() @IsString() notes?: string;
/** Impresso no bloco de assinatura do PDF de oferta ANATEL — cai para "Responsável Comercial" quando ausente. */
@IsOptional() @IsString() salesRepName?: string;
} }

View File

@@ -48,4 +48,5 @@ export class UpdateQuoteDto {
@IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string; @IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string;
@IsOptional() @IsString() @MinLength(0) notes?: string; @IsOptional() @IsString() @MinLength(0) notes?: string;
@IsOptional() @IsString() salesRepName?: string;
} }

View File

@@ -1,4 +1,5 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from "@nestjs/common"; import { Body, Controller, Get, Header, Param, Patch, Post, Query, Res, StreamableFile, UseGuards } from "@nestjs/common";
import type { Response } from "express";
import { ApiBearerAuth, ApiTags } from "@nestjs/swagger"; import { ApiBearerAuth, ApiTags } from "@nestjs/swagger";
import { QuotesService } from "./quotes.service.js"; import { QuotesService } from "./quotes.service.js";
import { CreateQuoteDto } from "./dto/create-quote.dto.js"; import { CreateQuoteDto } from "./dto/create-quote.dto.js";
@@ -43,6 +44,20 @@ export class QuotesController {
return this.quotes.create(dto, user); return this.quotes.create(dto, user);
} }
/** Anexo de conformidade ANATEL/RGC (Resolução 765/2023) — 400 se a oferta não tiver item STFC. Renderizado ao vivo, nunca cacheado (ver doc comment em QuotesService.generateAnatelPdf). */
@Get(":id/anatel-pdf")
@RequirePermission("quotes", "view")
@Header("Content-Type", "application/pdf")
async downloadAnatelPdf(
@Param("id") id: string,
@CurrentUser() user: EdenAuthenticatedUser,
@Res({ passthrough: true }) res: Response,
) {
const pdf = await this.quotes.generateAnatelPdf(id, user);
res.set({ "Content-Disposition": `inline; filename="anatel-${id}.pdf"` });
return new StreamableFile(pdf);
}
@Patch(":id") @Patch(":id")
@RequirePermission("quotes", "edit") @RequirePermission("quotes", "edit")
update( update(

View File

@@ -7,11 +7,14 @@ import {
} from "@nestjs/common"; } from "@nestjs/common";
import { import {
auditRepo, auditRepo,
clientRegistrationsRepo,
productsRepo, productsRepo,
quotesRepo, quotesRepo,
resellersRepo,
type Quote, type Quote,
type QuoteItem, type QuoteItem,
} from "@eden/database"; } from "@eden/database";
import { renderPdf } from "@eden/integrations";
import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js"; import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js";
import { maxGrantedScope } from "../auth/permissions.js"; import { maxGrantedScope } from "../auth/permissions.js";
import { import {
@@ -20,6 +23,7 @@ import {
getAvailablePaymentConditions, getAvailablePaymentConditions,
resolveApprovalStatus, resolveApprovalStatus,
} from "./pricing.js"; } from "./pricing.js";
import { buildAnatelOfferHtml, hasStfcItem } from "./anatel-offer-template.js";
import type { CreateQuoteDto } from "./dto/create-quote.dto.js"; import type { CreateQuoteDto } from "./dto/create-quote.dto.js";
import type { UpdateQuoteDto } from "./dto/update-quote.dto.js"; import type { UpdateQuoteDto } from "./dto/update-quote.dto.js";
import type { QuoteItemInputDto } from "./dto/quote-item-input.dto.js"; import type { QuoteItemInputDto } from "./dto/quote-item-input.dto.js";
@@ -51,6 +55,40 @@ export class QuotesService {
return quote; return quote;
} }
/**
* Anexo de conformidade ANATEL/RGC — sempre renderizado ao vivo a partir
* do estado atual da oferta (nunca cacheado/persistido como os
* documentos do módulo Documents), porque vários campos regulatórios
* são derivados de `updated_at` (Prazo de Comercialização, data-base de
* reajuste) — um PDF armazenado ficaria defasado assim que a oferta
* fosse editada de novo. 400 se a oferta não tiver nenhum item STFC: o
* documento inteiro existe para essa disclosure (art. 21 §3º do RGC),
* não faz sentido gerar um PDF "ANATEL" vazio de conteúdo regulatório.
*/
async generateAnatelPdf(id: string, user: EdenAuthenticatedUser): Promise<Buffer> {
const quote = await this.get(id, user);
if (!hasStfcItem(quote.items)) {
throw new BadRequestException(
"Esta oferta não tem nenhum item STFC — o anexo de conformidade ANATEL (RGC art. 21 §3º) só se aplica a ofertas com item STFC.",
);
}
const reseller = await resellersRepo.getResellerById(quote.reseller_id);
if (!reseller) throw new NotFoundException("Revenda da oferta não encontrada");
const clientDocument = { cpf: null as string | null, cnpj: null as string | null };
if (quote.client_registration_id) {
const registration = await clientRegistrationsRepo.getClientRegistrationById(quote.client_registration_id);
if (registration) {
clientDocument.cpf = registration.pf_cpf;
clientDocument.cnpj = registration.pj_cnpj;
}
}
const html = buildAnatelOfferHtml({ quote, reseller, clientDocument });
return renderPdf({ html });
}
async create(dto: CreateQuoteDto, user: EdenAuthenticatedUser): Promise<Quote> { async create(dto: CreateQuoteDto, user: EdenAuthenticatedUser): Promise<Quote> {
const items = await this.buildItems(dto.items, dto.contractPeriod); const items = await this.buildItems(dto.items, dto.contractPeriod);
const proposedMonthlyTotal = dto.proposedMonthlyTotal ?? null; const proposedMonthlyTotal = dto.proposedMonthlyTotal ?? null;
@@ -93,6 +131,7 @@ export class QuotesService {
approvalStatus, approvalStatus,
implPaymentCondition, implPaymentCondition,
notes: dto.notes ?? null, notes: dto.notes ?? null,
salesRepName: dto.salesRepName ?? null,
createdBy: user.id, createdBy: user.id,
}); });
@@ -166,6 +205,7 @@ export class QuotesService {
approvedById, approvedById,
implPaymentCondition, implPaymentCondition,
notes: dto.notes, notes: dto.notes,
salesRepName: dto.salesRepName,
}); });
if (!quote) throw new NotFoundException("Oferta não encontrada"); if (!quote) throw new NotFoundException("Oferta não encontrada");
@@ -303,6 +343,13 @@ export class QuotesService {
price24: product.price_24 != null ? Number(product.price_24) : null, price24: product.price_24 != null ? Number(product.price_24) : null,
price36: product.price_36 != null ? Number(product.price_36) : null, price36: product.price_36 != null ? Number(product.price_36) : null,
price48: product.price_48 != null ? Number(product.price_48) : null, price48: product.price_48 != null ? Number(product.price_48) : null,
// Snapshot do produto no momento em que o item é adicionado — ver
// o doc comment de QuoteItem.serviceType em @eden/database.
serviceType: product.service_type,
metered: product.metered,
minutesAllowance: product.minutes_allowance != null ? Number(product.minutes_allowance) : null,
tariffRates: product.tariff_rates,
hasLdi: product.has_ldi,
}); });
} }
return items; return items;

View File

@@ -0,0 +1,172 @@
import { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import { agent, bearer, closeTestApp, createTestApp, loginSuperAdmin, type AuthedTokens } from "./setup.js";
/**
* `renderPdf` is mocked (real call path through quotes.service.ts still
* runs) — what's under test is the ANATEL/RGC annex's computed content
* (anatel-offer-template.ts), not Chromium itself (covered by the
* documents-pdf.spec.ts suite + the manual smoke test in progress.md).
* Business-critical regulatory content: this asserts the actual HTML
* handed to the renderer contains the right computed fields, not just
* that the endpoint returns 200.
*/
const renderPdfMock = vi.fn(async (_input: { html: string }) => Buffer.from("%PDF-1.4 fake anatel pdf"));
vi.mock("@eden/integrations", () => ({
renderPdf: (input: { html: string }) => renderPdfMock(input),
}));
describe("quotes — anexo de conformidade ANATEL/RGC", () => {
let app: INestApplication;
let admin: AuthedTokens;
let resellerId: string;
let stfcProductId: string;
let pabxProductId: string;
beforeAll(async () => {
app = await createTestApp();
admin = await loginSuperAdmin(app);
const reseller = await agent(app)
.post("/api/v1/resellers")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Revenda ANATEL" })
.expect(201);
resellerId = reseller.body.id;
const stfcProduct = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({
name: "qa-test- Linha STFC",
productType: "service",
price0: 200,
price12: 150,
serviceType: "STFC",
metered: true,
minutesAllowance: 100,
hasLdi: true,
tariffRates: {
LC: { normal: 0.1, reduced: 0.1 },
LDN: { normal: 0.2, reduced: 0.2 },
VC1: { normal: 0.15, reduced: 0.15 },
VC2: { normal: 0.25, reduced: 0.25 },
VC3: { normal: 0.3, reduced: 0.3 },
LDI: { normal: 1.5, reduced: 1.5 },
},
})
.expect(201);
stfcProductId = stfcProduct.body.id;
expect(stfcProduct.body.service_type).toBe("STFC");
expect(stfcProduct.body.tariff_rates.LC).toEqual({ normal: 0.1, reduced: 0.1 });
const pabxProduct = await agent(app)
.post("/api/v1/products")
.set(...bearer(admin.accessToken))
.send({ name: "qa-test- Ramal PABX", productType: "service", price0: 50, serviceType: "Serviço PABX" })
.expect(201);
pabxProductId = pabxProduct.body.id;
});
afterAll(async () => {
await agent(app)
.patch(`/api/v1/products/${stfcProductId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await agent(app)
.patch(`/api/v1/products/${pabxProductId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await agent(app)
.patch(`/api/v1/resellers/${resellerId}`)
.set(...bearer(admin.accessToken))
.send({ active: false })
.expect(200);
await closeTestApp(app);
});
it("snapshots service_type/metered/tariffRates onto the quote item at creation", async () => {
const quote = await agent(app)
.post("/api/v1/quotes")
.set(...bearer(admin.accessToken))
.send({
resellerId,
clientName: "qa-test Cliente ANATEL",
clientPhone: "11999998888",
contractPeriod: 12,
items: [{ productId: stfcProductId, quantity: 2 }],
salesRepName: "qa-test Vendedor",
})
.expect(201);
const item = quote.body.items[0];
expect(item.serviceType).toBe("STFC");
expect(item.metered).toBe(true);
expect(item.hasLdi).toBe(true);
expect(item.tariffRates.LC).toEqual({ normal: 0.1, reduced: 0.1 });
expect(item.minutesAllowance).toBe(100);
});
it("renders the RGC annex with the right computed fields for an STFC quote", async () => {
renderPdfMock.mockClear();
const quote = await agent(app)
.post("/api/v1/quotes")
.set(...bearer(admin.accessToken))
.send({
resellerId,
clientName: "qa-test Cliente ANATEL 2",
clientPhone: "11999998888",
contractPeriod: 12,
items: [{ productId: stfcProductId, quantity: 1 }],
salesRepName: "qa-test Vendedor",
})
.expect(201);
const download = await agent(app)
.get(`/api/v1/quotes/${quote.body.id}/anatel-pdf`)
.set(...bearer(admin.accessToken))
.expect(200);
expect(download.headers["content-type"]).toBe("application/pdf");
expect(renderPdfMock).toHaveBeenCalledTimes(1);
const html = renderPdfMock.mock.calls[0]![0].html;
// Legal-basis section only renders because the quote has an STFC item.
expect(html).toContain("Conformidade RGC — Resolução Anatel nº 765/2023");
expect(html).toContain("Prazo de Comercialização");
expect(html).toContain(quote.body.quote_number);
expect(html).toContain("qa-test Cliente ANATEL 2");
expect(html).toContain("qa-test Vendedor");
// Tariff section, since the item is metered.
expect(html).toContain("TABELA DE TARIFAÇÃO");
expect(html).toContain("LDI");
expect(html).toContain("A tecnologia utilizada para entrega das chamadas do serviço STFC ofertado é VoIP.");
// Fixed regulatory fields.
expect(html).toContain("Pós-paga");
expect(html).toContain("Nacional");
expect(html).toContain("Intervalo mínimo de 12 meses");
});
it("rejects (400) a quote with no STFC item", async () => {
const quote = await agent(app)
.post("/api/v1/quotes")
.set(...bearer(admin.accessToken))
.send({
resellerId,
clientName: "qa-test Cliente Sem STFC",
clientPhone: "11999998888",
contractPeriod: 0,
items: [{ productId: pabxProductId, quantity: 1 }],
})
.expect(201);
const res = await agent(app)
.get(`/api/v1/quotes/${quote.body.id}/anatel-pdf`)
.set(...bearer(admin.accessToken))
.expect(400);
expect(res.body.message).toContain("STFC");
});
});

View File

@@ -11,6 +11,7 @@
"test": "echo 'no tests yet'" "test": "echo 'no tests yet'"
}, },
"dependencies": { "dependencies": {
"@eden/domain-shared": "workspace:*",
"@eden/ui": "workspace:*", "@eden/ui": "workspace:*",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",

View File

@@ -2,11 +2,12 @@
* Client for `apps/api` `/v1/documents/templates` and `/v1/documents/generations` * Client for `apps/api` `/v1/documents/templates` and `/v1/documents/generations`
* — the "templates + generation" half of Documents (eden.md módulo 4, §1.1-1.3). * — the "templates + generation" half of Documents (eden.md módulo 4, §1.1-1.3).
* *
* This leva has NO real visual editor and NO real PDF: `editorContent` is a * This leva has NO real visual editor: `editorContent` is a simplified
* simplified JSONB with a single root-level `plainText` string carrying * JSONB with a single root-level `plainText` string carrying
* `{{variavel}}` placeholders (no rich node tree), and "generation" just * `{{variavel}}` placeholders (no rich node tree). PDF generation itself
* merges variables into `renderedHtml` — `pdf_s3_key`/`pdf_sha256`/ * IS real (Chromium via packages/integrations/src/pdf/renderer.ts) —
* `pdf_size_bytes` are always `null` here. Don't build UI implying more. * `pdf_s3_key`/`pdf_sha256`/`pdf_size_bytes` are populated on every
* `status: 'final'` generation.
* *
* Like Contracts/Resellers, template/version/generation responses are * Like Contracts/Resellers, template/version/generation responses are
* snake_case straight off the DB row — except the two computed fields on a * snake_case straight off the DB row — except the two computed fields on a
@@ -14,7 +15,7 @@
* camelCase exactly as documented in the task brief; mirrored as-is below, * camelCase exactly as documented in the task brief; mirrored as-is below,
* not "fixed" to snake_case. * not "fixed" to snake_case.
*/ */
import { apiRequest } from "./http.js"; import { API_BASE_URL, apiRequest } from "./http.js";
export type DocGroup = "cliente" | "revenda"; export type DocGroup = "cliente" | "revenda";
export type DocumentVersionStatus = "draft" | "published"; export type DocumentVersionStatus = "draft" | "published";
@@ -184,6 +185,29 @@ export function createGeneration(
); );
} }
/**
* Fetches the generated PDF and triggers a browser download. Not routed
* through `apiRequest` (JSON-only) — this needs the raw binary response,
* but still needs the bearer token, so a plain `<a href>` won't work
* either (the API route is behind JwtAuthGuard). Standard SPA pattern:
* fetch as a Blob, hand the browser a temporary object URL.
*/
export async function downloadGenerationPdf(id: string, accessToken: string, filename: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/documents/generations/${id}/pdf`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error("Não foi possível baixar o PDF.");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}
/** Extracts `{{variavel}}` references from a plain-text template body — used to auto-build the generation context form. */ /** Extracts `{{variavel}}` references from a plain-text template body — used to auto-build the generation context form. */
export function extractTemplateVariables(plainText: string): string[] { export function extractTemplateVariables(plainText: string): string[] {
const found = new Set<string>(); const found = new Set<string>();

View File

@@ -4,6 +4,9 @@
* truth — shapes below are mirrored, not invented). * truth — shapes below are mirrored, not invented).
*/ */
import { apiRequest } from "./http.js"; import { apiRequest } from "./http.js";
import type { ServiceType, TariffRates } from "@eden/domain-shared";
export type { ServiceType, TariffRates };
/** /**
* Response shape — snake_case, exactly as returned by the `products` table. * Response shape — snake_case, exactly as returned by the `products` table.
@@ -36,6 +39,12 @@ export interface Product {
price_36: string | null; price_36: string | null;
price_48: string | null; price_48: string | null;
impl_unit_price: string; impl_unit_price: string;
/** Recorte regulatório RGC (Resolução Anatel 765/2023) — usado pelo anexo de conformidade ANATEL do PDF de oferta (Quotes). */
service_type: ServiceType;
metered: boolean;
minutes_allowance: string | null;
tariff_rates: Partial<TariffRates>;
has_ldi: boolean;
} }
/** Request body — camelCase, matches `CreateProductDto` exactly. */ /** Request body — camelCase, matches `CreateProductDto` exactly. */
@@ -59,6 +68,11 @@ export interface CreateProductInput {
price36?: number; price36?: number;
price48?: number; price48?: number;
implUnitPrice?: number; implUnitPrice?: number;
serviceType?: ServiceType;
metered?: boolean;
minutesAllowance?: number;
tariffRates?: Partial<TariffRates>;
hasLdi?: boolean;
} }
/** Matches `UpdateProductDto`: everything optional. */ /** Matches `UpdateProductDto`: everything optional. */

View File

@@ -11,7 +11,8 @@
* `contract_total`, `proposed_monthly_total`) come back as NUMERIC-typed * `contract_total`, `proposed_monthly_total`) come back as NUMERIC-typed
* strings from Postgres — always `Number(...)` before formatting/math. * strings from Postgres — always `Number(...)` before formatting/math.
*/ */
import { apiRequest } from "./http.js"; import { API_BASE_URL, apiRequest } from "./http.js";
import type { ServiceType, TariffRates } from "@eden/domain-shared";
export const CONTRACT_PERIODS = [0, 12, 24, 36, 48] as const; export const CONTRACT_PERIODS = [0, 12, 24, 36, 48] as const;
export type ContractPeriod = (typeof CONTRACT_PERIODS)[number]; export type ContractPeriod = (typeof CONTRACT_PERIODS)[number];
@@ -35,6 +36,12 @@ export interface QuoteItem {
price24: number | null; price24: number | null;
price36: number | null; price36: number | null;
price48: number | null; price48: number | null;
/** Snapshot do produto no momento em que o item foi adicionado — usado pelo anexo de conformidade ANATEL do PDF de oferta. */
serviceType: ServiceType;
metered: boolean;
minutesAllowance: number | null;
tariffRates: Partial<TariffRates>;
hasLdi: boolean;
} }
export interface Quote { export interface Quote {
@@ -63,6 +70,8 @@ export interface Quote {
deal_status: DealStatus; deal_status: DealStatus;
impl_payment_condition: ImplPaymentCondition; impl_payment_condition: ImplPaymentCondition;
client_registration_id: string | null; client_registration_id: string | null;
/** Impresso no bloco de assinatura do PDF de oferta ANATEL — cai para "Responsável Comercial" quando ausente. */
sales_rep_name: string | null;
} }
export interface QuoteItemInput { export interface QuoteItemInput {
@@ -83,6 +92,7 @@ export interface CreateQuoteInput {
proposedMonthlyTotal?: number; proposedMonthlyTotal?: number;
implPaymentCondition?: ImplPaymentCondition; implPaymentCondition?: ImplPaymentCondition;
notes?: string; notes?: string;
salesRepName?: string;
} }
export interface DecideApprovalInput { export interface DecideApprovalInput {
@@ -122,3 +132,25 @@ export function decideQuoteApproval(
export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise<Quote> { export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise<Quote> {
return apiRequest<Quote>(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken }); return apiRequest<Quote>(`/quotes/${id}/close`, { method: "POST", body: JSON.stringify(input) }, { accessToken });
} }
/** Mirrors QuotesService.generateAnatelPdf's gate — the endpoint 400s otherwise, so the UI hides the button rather than surfacing an error. */
export function hasStfcItem(items: QuoteItem[]): boolean {
return items.some((item) => item.serviceType === "STFC");
}
/** Same pattern as documents.ts's downloadGenerationPdf — binary response behind a bearer token, not through apiRequest (JSON-only). */
export async function downloadAnatelPdf(id: string, accessToken: string, filename: string): Promise<void> {
const response = await fetch(`${API_BASE_URL}/quotes/${id}/anatel-pdf`, {
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!response.ok) {
throw new Error("Não foi possível baixar o PDF de conformidade ANATEL.");
}
const blob = await response.blob();
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = filename;
link.click();
URL.revokeObjectURL(url);
}

View File

@@ -1,10 +1,10 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { Alert, Badge, Card, PageHeader, Table } from "@eden/ui"; import { Alert, Badge, Button, Card, PageHeader, Table } from "@eden/ui";
import type { TableColumn } from "@eden/ui"; import type { TableColumn } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js"; import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js"; import { ApiError } from "../../lib/api/http.js";
import { listDocumentTemplates, listGenerations } from "../../lib/api/documents.js"; import { downloadGenerationPdf, listDocumentTemplates, listGenerations } from "../../lib/api/documents.js";
import type { DocumentGeneration, DocumentTemplate } from "../../lib/api/documents.js"; import type { DocumentGeneration, DocumentTemplate } from "../../lib/api/documents.js";
import { listClientRegistrations } from "../../lib/api/client-registrations.js"; import { listClientRegistrations } from "../../lib/api/client-registrations.js";
import type { ClientRegistration } from "../../lib/api/client-registrations.js"; import type { ClientRegistration } from "../../lib/api/client-registrations.js";
@@ -31,6 +31,7 @@ export function GenerationsListPage() {
const [clientRegistrations, setClientRegistrations] = useState<ClientRegistration[]>([]); const [clientRegistrations, setClientRegistrations] = useState<ClientRegistration[]>([]);
const [resellers, setResellers] = useState<Reseller[]>([]); const [resellers, setResellers] = useState<Reseller[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [downloadingId, setDownloadingId] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (!accessToken) return; if (!accessToken) return;
@@ -61,6 +62,21 @@ export function GenerationsListPage() {
const clientById = useMemo(() => new Map(clientRegistrations.map((c) => [c.id, c])), [clientRegistrations]); const clientById = useMemo(() => new Map(clientRegistrations.map((c) => [c.id, c])), [clientRegistrations]);
const resellerById = useMemo(() => new Map(resellers.map((r) => [r.id, r])), [resellers]); const resellerById = useMemo(() => new Map(resellers.map((r) => [r.id, r])), [resellers]);
async function handleDownload(generation: DocumentGeneration) {
if (!accessToken) return;
setError(null);
setDownloadingId(generation.id);
try {
const template = templateById.get(generation.template_id);
const filename = `${template?.key ?? "documento"}-${generation.id.slice(0, 8)}.pdf`;
await downloadGenerationPdf(generation.id, accessToken, filename);
} catch {
setError("Não foi possível baixar o PDF. Tente novamente.");
} finally {
setDownloadingId(null);
}
}
const columns: Array<TableColumn<DocumentGeneration>> = [ const columns: Array<TableColumn<DocumentGeneration>> = [
{ {
key: "template", key: "template",
@@ -115,6 +131,23 @@ export function GenerationsListPage() {
header: "Data", header: "Data",
render: (generation) => formatDateTimeBR(generation.created_at), render: (generation) => formatDateTimeBR(generation.created_at),
}, },
{
key: "pdf",
header: "PDF",
render: (generation) =>
generation.pdf_s3_key ? (
<Button
variant="secondary"
size="sm"
loading={downloadingId === generation.id}
onClick={() => handleDownload(generation)}
>
Baixar
</Button>
) : (
"—"
),
},
]; ];
return ( return (

View File

@@ -1,7 +1,9 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import type { FormEvent } from "react"; import type { FormEvent } from "react";
import { Link, useNavigate, useParams } from "react-router-dom"; import { Link, useNavigate, useParams } from "react-router-dom";
import { Alert, Button, Card, Input, PageHeader } from "@eden/ui"; import { Alert, Button, Card, Input, PageHeader, Select } from "@eden/ui";
import { SERVICE_TYPES, TARIFF_TYPES } from "@eden/domain-shared";
import type { ServiceType, TariffType } from "@eden/domain-shared";
import { createProduct, getProduct, updateProduct } from "../../lib/api/products.js"; import { createProduct, getProduct, updateProduct } from "../../lib/api/products.js";
import type { CreateProductInput } from "../../lib/api/products.js"; import type { CreateProductInput } from "../../lib/api/products.js";
import { ApiError } from "../../lib/api/http.js"; import { ApiError } from "../../lib/api/http.js";
@@ -59,6 +61,18 @@ interface ProductFormState {
price36Cents: number; price36Cents: number;
price48Cents: number; price48Cents: number;
implUnitPriceCents: number; implUnitPriceCents: number;
// Recorte regulatório RGC (Resolução Anatel 765/2023) — usado pelo anexo
// de conformidade ANATEL do PDF de oferta (Quotes). Só é relevante para
// produtos STFC/PABX de fato tarifados por minuto.
serviceType: ServiceType;
metered: boolean;
minutesAllowance: string;
hasLdi: boolean;
tariffRatesCents: Record<TariffType, { normal: number; reduced: number }>;
}
function emptyTariffRatesCents(): ProductFormState["tariffRatesCents"] {
return Object.fromEntries(TARIFF_TYPES.map((t) => [t, { normal: 0, reduced: 0 }])) as ProductFormState["tariffRatesCents"];
} }
const EMPTY_FORM: ProductFormState = { const EMPTY_FORM: ProductFormState = {
@@ -81,6 +95,11 @@ const EMPTY_FORM: ProductFormState = {
price36Cents: 0, price36Cents: 0,
price48Cents: 0, price48Cents: 0,
implUnitPriceCents: 0, implUnitPriceCents: 0,
serviceType: "STFC",
metered: false,
minutesAllowance: "",
hasLdi: false,
tariffRatesCents: emptyTariffRatesCents(),
}; };
function toOptional(value: string): string | undefined { function toOptional(value: string): string | undefined {
@@ -126,6 +145,19 @@ export function ProductFormPage() {
price36Cents: Math.round(Number(product.price_36 ?? 0) * 100), price36Cents: Math.round(Number(product.price_36 ?? 0) * 100),
price48Cents: Math.round(Number(product.price_48 ?? 0) * 100), price48Cents: Math.round(Number(product.price_48 ?? 0) * 100),
implUnitPriceCents: Math.round(Number(product.impl_unit_price ?? 0) * 100), implUnitPriceCents: Math.round(Number(product.impl_unit_price ?? 0) * 100),
serviceType: product.service_type,
metered: product.metered,
minutesAllowance: product.minutes_allowance ?? "",
hasLdi: product.has_ldi,
tariffRatesCents: Object.fromEntries(
TARIFF_TYPES.map((t) => [
t,
{
normal: Math.round((product.tariff_rates[t]?.normal ?? 0) * 100),
reduced: Math.round((product.tariff_rates[t]?.reduced ?? 0) * 100),
},
]),
) as ProductFormState["tariffRatesCents"],
}); });
}) })
.catch((err) => { .catch((err) => {
@@ -169,6 +201,16 @@ export function ProductFormPage() {
price36: form.price36Cents / 100, price36: form.price36Cents / 100,
price48: form.price48Cents / 100, price48: form.price48Cents / 100,
implUnitPrice: form.implUnitPriceCents / 100, implUnitPrice: form.implUnitPriceCents / 100,
serviceType: form.serviceType,
metered: form.metered,
minutesAllowance: form.minutesAllowance.trim() === "" ? undefined : Number(form.minutesAllowance),
hasLdi: form.hasLdi,
tariffRates: Object.fromEntries(
TARIFF_TYPES.map((t) => [
t,
{ normal: form.tariffRatesCents[t].normal / 100, reduced: form.tariffRatesCents[t].reduced / 100 },
]),
),
}; };
if (isEditing && id) { if (isEditing && id) {
@@ -270,6 +312,91 @@ export function ProductFormPage() {
</div> </div>
</Card> </Card>
<Card title="Regulatório (ANATEL/RGC)">
<p className="mb-4 text-sm text-default">
Usado no anexo de conformidade ANATEL do PDF de oferta — só relevante para produtos de telefonia (STFC)
faturados por minuto.
</p>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<Select
label="Tipo de serviço"
value={form.serviceType}
onChange={(e) => updateField("serviceType", e.target.value as ServiceType)}
options={SERVICE_TYPES.map((t) => ({ value: t, label: t }))}
/>
<Input
label="Franquia de minutos"
inputMode="numeric"
value={form.minutesAllowance}
onChange={(e) => updateField("minutesAllowance", e.target.value.replace(/\D/g, ""))}
placeholder="Deixe em branco se não houver"
/>
<label className="flex items-center gap-2 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form.metered}
onChange={(e) => updateField("metered", e.target.checked)}
/>
Tarifado por minuto (habilita a tabela de tarifação abaixo)
</label>
<label className="flex items-center gap-2 text-sm text-title">
<input
type="checkbox"
className="shrink-0 rounded-sm border-border-color text-primary focus:ring-primary"
checked={form.hasLdi}
onChange={(e) => updateField("hasLdi", e.target.checked)}
/>
Permite LDI (longa distância internacional)
</label>
</div>
{form.metered ? (
<div className="mt-4 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-default">
<th className="py-1 pr-2">Tipo</th>
<th className="py-1 pr-2">Normal</th>
<th className="py-1">Reduzido</th>
</tr>
</thead>
<tbody>
{TARIFF_TYPES.filter((t) => t !== "LDI" || form.hasLdi).map((type) => (
<tr key={type}>
<td className="py-1 pr-2 font-mono">{type}</td>
<td className="py-1 pr-2">
<CurrencyInput
label=""
cents={form.tariffRatesCents[type].normal}
onChange={(v) =>
updateField("tariffRatesCents", {
...form.tariffRatesCents,
[type]: { ...form.tariffRatesCents[type], normal: v },
})
}
/>
</td>
<td className="py-1">
<CurrencyInput
label=""
cents={form.tariffRatesCents[type].reduced}
onChange={(v) =>
updateField("tariffRatesCents", {
...form.tariffRatesCents,
[type]: { ...form.tariffRatesCents[type], reduced: v },
})
}
/>
</td>
</tr>
))}
</tbody>
</table>
</div>
) : null}
</Card>
<Card title="Rastreamento e status"> <Card title="Rastreamento e status">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2"> <div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{( {(

View File

@@ -6,7 +6,7 @@ import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui"
import type { BadgeVariant } from "@eden/ui"; import type { BadgeVariant } from "@eden/ui";
import { useAuth } from "../../lib/auth/AuthContext.js"; import { useAuth } from "../../lib/auth/AuthContext.js";
import { ApiError } from "../../lib/api/http.js"; import { ApiError } from "../../lib/api/http.js";
import { closeQuoteDeal, decideQuoteApproval, getQuote } from "../../lib/api/quotes.js"; import { closeQuoteDeal, decideQuoteApproval, downloadAnatelPdf, getQuote, hasStfcItem } from "../../lib/api/quotes.js";
import type { ApprovalStatus, DealStatus, Quote } from "../../lib/api/quotes.js"; import type { ApprovalStatus, DealStatus, Quote } from "../../lib/api/quotes.js";
import { listResellers } from "../../lib/api/resellers.js"; import { listResellers } from "../../lib/api/resellers.js";
import type { Reseller } from "../../lib/api/resellers.js"; import type { Reseller } from "../../lib/api/resellers.js";
@@ -58,6 +58,8 @@ export function QuoteDetailPage() {
const [reseller, setReseller] = useState<Reseller | null>(null); const [reseller, setReseller] = useState<Reseller | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [downloadingAnatel, setDownloadingAnatel] = useState(false);
const [anatelError, setAnatelError] = useState<string | null>(null);
const load = useCallback(() => { const load = useCallback(() => {
if (!id || !accessToken) return; if (!id || !accessToken) return;
@@ -76,6 +78,19 @@ export function QuoteDetailPage() {
load(); load();
}, [load]); }, [load]);
async function handleDownloadAnatel() {
if (!id || !accessToken || !quote) return;
setAnatelError(null);
setDownloadingAnatel(true);
try {
await downloadAnatelPdf(id, accessToken, `anatel-${quote.quote_number}.pdf`);
} catch {
setAnatelError("Não foi possível baixar o PDF de conformidade ANATEL. Tente novamente.");
} finally {
setDownloadingAnatel(false);
}
}
if (loading) { if (loading) {
return ( return (
<div> <div>
@@ -103,6 +118,16 @@ export function QuoteDetailPage() {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Badge variant={APPROVAL_VARIANT[quote.approval_status]}>{APPROVAL_LABEL[quote.approval_status]}</Badge> <Badge variant={APPROVAL_VARIANT[quote.approval_status]}>{APPROVAL_LABEL[quote.approval_status]}</Badge>
<Badge variant={DEAL_VARIANT[quote.deal_status]}>{DEAL_LABEL[quote.deal_status]}</Badge> <Badge variant={DEAL_VARIANT[quote.deal_status]}>{DEAL_LABEL[quote.deal_status]}</Badge>
{hasStfcItem(quote.items) ? (
<Button
type="button"
variant="secondary"
loading={downloadingAnatel}
onClick={handleDownloadAnatel}
>
Baixar PDF ANATEL
</Button>
) : null}
{quote.deal_status === "fechado" ? ( {quote.deal_status === "fechado" ? (
<Link <Link
to={`/contracts/new?quoteId=${encodeURIComponent(quote.id)}${ to={`/contracts/new?quoteId=${encodeURIComponent(quote.id)}${
@@ -119,6 +144,12 @@ export function QuoteDetailPage() {
} }
/> />
{anatelError ? (
<Alert variant="error" className="mb-4">
{anatelError}
</Alert>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-3"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-3">
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<Card title="Cliente"> <Card title="Cliente">
@@ -137,6 +168,7 @@ export function QuoteDetailPage() {
value={quote.fidelity_period != null ? `${quote.fidelity_period} meses` : "—"} value={quote.fidelity_period != null ? `${quote.fidelity_period} meses` : "—"}
/> />
<Row label="Condição de pagamento" value={quote.impl_payment_condition} /> <Row label="Condição de pagamento" value={quote.impl_payment_condition} />
<Row label="Responsável comercial" value={quote.sales_rep_name ?? "—"} />
<Row label="Criada em" value={formatDateBR(quote.created_at)} /> <Row label="Criada em" value={formatDateBR(quote.created_at)} />
</dl> </dl>
</Card> </Card>

View File

@@ -56,6 +56,7 @@ export function QuoteFormPage() {
const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState("");
const [implPaymentCondition, setImplPaymentCondition] = useState<ImplPaymentCondition | "">(""); const [implPaymentCondition, setImplPaymentCondition] = useState<ImplPaymentCondition | "">("");
const [notes, setNotes] = useState(""); const [notes, setNotes] = useState("");
const [salesRepName, setSalesRepName] = useState("");
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -134,6 +135,7 @@ export function QuoteFormPage() {
proposedMonthlyTotal: proposedMonthlyTotal ? Number(proposedMonthlyTotal) : undefined, proposedMonthlyTotal: proposedMonthlyTotal ? Number(proposedMonthlyTotal) : undefined,
implPaymentCondition: implPaymentCondition || undefined, implPaymentCondition: implPaymentCondition || undefined,
notes: notes || undefined, notes: notes || undefined,
salesRepName: salesRepName || undefined,
}, },
accessToken, accessToken,
); );
@@ -346,6 +348,13 @@ export function QuoteFormPage() {
</div> </div>
</div> </div>
<Input
label="Responsável comercial"
value={salesRepName}
onChange={(e) => setSalesRepName(e.target.value)}
hint='Impresso no PDF de oferta — cai para "Responsável Comercial" quando ausente.'
/>
<div> <div>
<label className="mb-1 block text-sm font-semibold text-gray-900">Notas</label> <label className="mb-1 block text-sm font-semibold text-gray-900">Notas</label>
<textarea <textarea

View File

@@ -484,3 +484,35 @@ Teste de integração novo (`test/documents-pdf.spec.ts`, 3 testes, `@eden/integ
### Pendências ### Pendências
PDF de Oferta/Orçamento compatível com ANATEL (Resolução 765/2023) ainda não construído — próximo item natural desta sequência, usando o mesmo `renderPdf`. Módulo de Assinatura Eletrônica completo (envelopes/signers/OTP/hash-chain) ainda não iniciado — item maior, sensível o suficiente para merecer leva própria dedicada, não apressada. Regras de negócio do legado em torno de edição/trava de Orçamento-como-Contrato (lock ao vincular `client_registration_id`, override só por `super_admin`) ainda não avaliadas contra o modelo de Contrato do EDEN (ADR-0006). PDF de Oferta/Orçamento compatível com ANATEL (Resolução 765/2023) ainda não construído — próximo item natural desta sequência, usando o mesmo `renderPdf`. Módulo de Assinatura Eletrônica completo (envelopes/signers/OTP/hash-chain) ainda não iniciado — item maior, sensível o suficiente para merecer leva própria dedicada, não apressada. Regras de negócio do legado em torno de edição/trava de Orçamento-como-Contrato (lock ao vincular `client_registration_id`, override só por `super_admin`) ainda não avaliadas contra o modelo de Contrato do EDEN (ADR-0006).
---
## Anexo de conformidade ANATEL/RGC no PDF de Oferta (Quotes)
Data: 2026-09-04. Continuação direta da leva anterior — usuário confirmou que a parte de conteúdo regulatório precisava ser exata, não aproximada de memória. Dois subagentes `fork` extraíram o conteúdo verbatim do legado antes de qualquer código ser escrito: (1) `BackofficeAnatelPDFTemplate.jsx` (texto legal, fórmulas computadas, ordem dos campos), (2) o schema real de `service_type`/`metered`/`tariff_rates`/`has_ldi` em `products` (baseline-schema.js).
### Decisão de escopo confirmada com o usuário
O anexo do legado depende de `service_type`/`metered`/`tariff_rates`/`has_ldi` por item — campos que a migration `1788438907074_products.cjs` **deliberadamente** excluiu ("legacy telecom-billing specifics que belong to a later phase"). Perguntei ao usuário como proceder (gerar conteúdo regulatório sobre uma base incompleta era um risco real de compliance) — escolheu **expandir o schema primeiro**, com fidelidade total ao legado incluindo a seção de tarifação STFC.
### Schema (`packages/database/migrations/1788525270647_products-service-type-tariff.cjs`)
`products` ganha `service_type` (CHECK nos 4 valores do legado: STFC/Serviço PABX/SVA (locação)/Serviço software), `metered`, `minutes_allowance`, `tariff_rates` (jsonb, formato `{TIPO: {normal, reduced}}`), `has_ldi`. `quotes` ganha `sales_rep_name` (impresso no bloco de assinatura). Novo pacote compartilhado `packages/domain-shared/src/telecom/service-types.ts` (catálogo de tipos, legendas de tarifação verbatim do legado, `formatCPF`/`formatCNPJ` adicionados a `br-documents.ts`) — consumido por `@eden/database`, `apps/api` e `apps/core-web`.
**Snapshot, não referência viva**: `QuotesService.buildItems()` copia `service_type`/`metered`/`minutesAllowance`/`tariffRates`/`hasLdi` do produto para o item no momento da criação da oferta (mesmo comportamento do legado, confirmado pelo subagente) — editar o produto depois nunca muda retroativamente a disclosure regulatória de uma oferta já emitida.
### `apps/api/src/quotes/anatel-offer-template.ts` (novo)
Gera o HTML completo do anexo — todos os campos do legado portados com fidelidade: Prazo de Comercialização (10 dias corridos a partir de `quote.updated_at`, rótulo exato do art. 3º XI, confirmado que NÃO é "Prazo de Oferta"), Prazo de Vigência/Permanência, Multa por Rescisão Antecipada (usa `computeSavings().beneficioTotalFidelidade` — `pricing.ts` já tinha essa fórmula pronta desde a leva de Quotes, nunca usada até agora), Critério/Data-base de Reajuste, tabela de itens agrupada por `service_type`, seção de Tarifação (LC/LDN/VC1/VC2/VC3/LDI, só renderiza para itens `metered`, legenda verbatim), condição especial, resumo financeiro, assinaturas. Placeholder `[A DEFINIR]` para CPF/CNPJ do cliente quando a oferta ainda não tem `client_registration_id` vinculado (nunca inventa um documento).
**Simplificação deliberada e documentada**: sem o split automático software/não-software do legado (`splitQuoteByServiceType.js`) — esta leva sempre gera um único PDF cobrindo todos os itens da oferta. Renderizado sempre ao vivo (nunca cacheado como os Documents), porque o conteúdo regulatório deriva de `updated_at` — um PDF salvo ficaria defasado assim que a oferta fosse editada de novo.
Novo endpoint `GET /v1/quotes/:id/anatel-pdf` — 400 se a oferta não tiver nenhum item STFC (o documento inteiro existe para essa disclosure, art. 21 §3º do RGC).
### Frontend
`ProductFormPage`: novo card "Regulatório (ANATEL/RGC)" — tipo de serviço, franquia de minutos, tarifado por minuto, LDI, tabela de tarifas (só aparece se "tarifado por minuto" marcado). `QuoteFormPage`: campo "Responsável comercial". `QuoteDetailPage`: botão "Baixar PDF ANATEL" (só aparece se a oferta tiver item STFC, mesma regra do backend) + campo "Responsável comercial" no resumo.
### Verificação
Teste de integração novo (`test/anatel-pdf.spec.ts`, 3 testes, `renderPdf` mockado como nos outros specs de PDF — o que está sob teste é o conteúdo regulatório computado, não o Chromium em si): confirma snapshot correto dos campos do produto no item, todos os campos regulatórios computados corretos no HTML (datas, fórmula da multa, tabela de tarifação, texto VoIP, campos fixos), e 400 para oferta sem item STFC. Suíte completa: 39/39 verdes (36→39). `pnpm turbo run typecheck build --force` (28/28).

View File

@@ -0,0 +1,61 @@
/**
* Adds the legacy telecom-billing specifics that 1788438907074_products.cjs
* deliberately deferred ("service_type/metered/tariff_rates/ixc_product_code
* — those are legacy telecom-billing specifics that belong to a later
* phase, not invented here"). That later phase is now: the ANATEL/RGC
* compliance annex (Resolução Anatel nº 765/2023) needs to know which
* products are STFC (art. 21 §3º), whether they're metered per-minute,
* and their LC/LDN/VC1/VC2/VC3/LDI tariff rates to print the required
* disclosure. Column shapes and the 4-value service_type list are ported
* with exact fidelity from the legacy's baseline-schema.js products table.
*
* `quotes.sales_rep_name` rides along here (small, single-column, same
* ANATEL-annex consumer — printed in the document's signature block) for
* the same reason can_approve_discount rode along in 1788445280742_quotes.
*
* service_type/metered/minutes_allowance/tariff_rates/has_ldi are snapshot
* onto quotes.items (a JSONB blob, no schema change needed there) at
* quote-creation time — see apps/api/src/quotes/quotes.service.ts
* buildItems() — so a later product edit never retroactively changes an
* already-issued quote's regulatory disclosure (ported from the legacy's
* same snapshot behavior, NewQuote.jsx).
*/
exports.shorthands = undefined;
exports.up = (pgm) => {
pgm.sql(`
ALTER TABLE products
ADD COLUMN service_type TEXT NOT NULL DEFAULT 'STFC'
CHECK (service_type IN ('STFC', 'Serviço PABX', 'SVA (locação)', 'Serviço software')),
ADD COLUMN metered BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN minutes_allowance NUMERIC(12,2),
ADD COLUMN tariff_rates JSONB NOT NULL DEFAULT '{}'::jsonb,
ADD COLUMN has_ldi BOOLEAN NOT NULL DEFAULT false;
COMMENT ON COLUMN products.service_type IS
'Recorte regulatório RGC (Resolução Anatel 765/2023) — só estes 4 valores existem no legado, ported verbatim. Gate para a seção de conformidade ANATEL no PDF de oferta (só aparece se algum item da oferta for STFC).';
COMMENT ON COLUMN products.metered IS
'Cobrança por minuto (chamadas STFC) — gate para a tabela de tarifação (LC/LDN/VC1/VC2/VC3[/LDI]) no PDF de oferta.';
COMMENT ON COLUMN products.tariff_rates IS
'Tarifas por tipo de chamada, formato {TIPO: {normal, reduced}} — chaves possíveis LC/LDN/VC1/VC2/VC3/LDI (ver packages/domain-shared para o catálogo de tipos). LDI só é exibido na tabela quando has_ldi=true, mas a chave pode existir no JSON de qualquer forma.';
COMMENT ON COLUMN products.has_ldi IS
'Habilita a coluna/linha de legenda LDI (longa distância internacional) na tabela de tarifação.';
ALTER TABLE quotes ADD COLUMN sales_rep_name TEXT;
COMMENT ON COLUMN quotes.sales_rep_name IS
'Nome do responsável comercial impresso no bloco de assinatura do PDF de oferta — texto livre, opcional (cai para "Responsável Comercial" genérico quando ausente).';
`);
};
exports.down = (pgm) => {
pgm.sql(`
ALTER TABLE quotes DROP COLUMN IF EXISTS sales_rep_name;
ALTER TABLE products
DROP COLUMN IF EXISTS service_type,
DROP COLUMN IF EXISTS metered,
DROP COLUMN IF EXISTS minutes_allowance,
DROP COLUMN IF EXISTS tariff_rates,
DROP COLUMN IF EXISTS has_ldi;
`);
};

View File

@@ -14,6 +14,7 @@
"migrate:create": "node-pg-migrate create" "migrate:create": "node-pg-migrate create"
}, },
"dependencies": { "dependencies": {
"@eden/domain-shared": "workspace:*",
"pg": "^8.13.1" "pg": "^8.13.1"
}, },
"devDependencies": { "devDependencies": {

View File

@@ -1,4 +1,5 @@
import { query } from "../pool.js"; import { query } from "../pool.js";
import type { ServiceType, TariffRates } from "@eden/domain-shared";
export interface Product { export interface Product {
id: string; id: string;
@@ -25,6 +26,11 @@ export interface Product {
price_36: string | null; price_36: string | null;
price_48: string | null; price_48: string | null;
impl_unit_price: string; impl_unit_price: string;
service_type: ServiceType;
metered: boolean;
minutes_allowance: string | null;
tariff_rates: TariffRates;
has_ldi: boolean;
} }
export async function listProducts(includeInactive = false): Promise<Product[]> { export async function listProducts(includeInactive = false): Promise<Product[]> {
@@ -61,6 +67,11 @@ export interface CreateProductInput {
price36?: number | null; price36?: number | null;
price48?: number | null; price48?: number | null;
implUnitPrice?: number; implUnitPrice?: number;
serviceType?: ServiceType;
metered?: boolean;
minutesAllowance?: number | null;
tariffRates?: TariffRates;
hasLdi?: boolean;
createdBy?: string | null; createdBy?: string | null;
} }
@@ -69,11 +80,13 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
`INSERT INTO products ( `INSERT INTO products (
name, description, product_type, product_group, product_subgroup, brand, unit, name, description, product_type, product_group, product_subgroup, brand, unit,
active, discontinued, tracks_stock, tracks_serial, tracks_asset_tag, tracks_mac, active, discontinued, tracks_stock, tracks_serial, tracks_asset_tag, tracks_mac,
price_0, price_12, price_24, price_36, price_48, impl_unit_price, created_by price_0, price_12, price_24, price_36, price_48, impl_unit_price,
service_type, metered, minutes_allowance, tariff_rates, has_ldi, created_by
) VALUES ( ) VALUES (
$1,$2,$3,$4,$5,$6,COALESCE($7,'unidade'), $1,$2,$3,$4,$5,$6,COALESCE($7,'unidade'),
COALESCE($8,true),COALESCE($9,false),COALESCE($10,false),COALESCE($11,false),COALESCE($12,false),COALESCE($13,false), COALESCE($8,true),COALESCE($9,false),COALESCE($10,false),COALESCE($11,false),COALESCE($12,false),COALESCE($13,false),
$14,$15,$16,$17,$18,COALESCE($19,0),$20 $14,$15,$16,$17,$18,COALESCE($19,0),
COALESCE($20,'STFC'),COALESCE($21,false),$22,COALESCE($23,'{}'::jsonb),COALESCE($24,false),$25
) )
RETURNING *`, RETURNING *`,
[ [
@@ -96,6 +109,11 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
input.price36 ?? null, input.price36 ?? null,
input.price48 ?? null, input.price48 ?? null,
input.implUnitPrice ?? null, input.implUnitPrice ?? null,
input.serviceType ?? null,
input.metered ?? null,
input.minutesAllowance ?? null,
input.tariffRates != null ? JSON.stringify(input.tariffRates) : null,
input.hasLdi ?? null,
input.createdBy ?? null, input.createdBy ?? null,
], ],
); );
@@ -127,7 +145,12 @@ export async function updateProduct(id: string, input: UpdateProductInput): Prom
price_24 = COALESCE($17, price_24), price_24 = COALESCE($17, price_24),
price_36 = COALESCE($18, price_36), price_36 = COALESCE($18, price_36),
price_48 = COALESCE($19, price_48), price_48 = COALESCE($19, price_48),
impl_unit_price = COALESCE($20, impl_unit_price) impl_unit_price = COALESCE($20, impl_unit_price),
service_type = COALESCE($21, service_type),
metered = COALESCE($22, metered),
minutes_allowance = COALESCE($23, minutes_allowance),
tariff_rates = COALESCE($24, tariff_rates),
has_ldi = COALESCE($25, has_ldi)
WHERE id = $1 WHERE id = $1
RETURNING *`, RETURNING *`,
[ [
@@ -151,6 +174,11 @@ export async function updateProduct(id: string, input: UpdateProductInput): Prom
input.price36 ?? null, input.price36 ?? null,
input.price48 ?? null, input.price48 ?? null,
input.implUnitPrice ?? null, input.implUnitPrice ?? null,
input.serviceType ?? null,
input.metered ?? null,
input.minutesAllowance ?? null,
input.tariffRates != null ? JSON.stringify(input.tariffRates) : null,
input.hasLdi ?? null,
], ],
); );
return rows[0] ?? null; return rows[0] ?? null;

View File

@@ -1,4 +1,5 @@
import { query } from "../pool.js"; import { query } from "../pool.js";
import type { ServiceType, TariffRates } from "@eden/domain-shared";
export interface QuoteItem { export interface QuoteItem {
productId: string; productId: string;
@@ -13,6 +14,18 @@ export interface QuoteItem {
price24: number | null; price24: number | null;
price36: number | null; price36: number | null;
price48: number | null; price48: number | null;
/**
* Snapshot do produto no momento em que o item foi adicionado à oferta
* (não uma referência viva) — eden.md/legado: editar o produto depois
* nunca muda retroativamente a disclosure regulatória de uma oferta já
* emitida. Usado só pelo anexo de conformidade ANATEL
* (apps/api/src/quotes/anatel-offer-template.ts).
*/
serviceType: ServiceType;
metered: boolean;
minutesAllowance: number | null;
tariffRates: TariffRates;
hasLdi: boolean;
} }
export interface Quote { export interface Quote {
@@ -41,6 +54,7 @@ export interface Quote {
deal_status: "orcamento" | "fechado" | "perdido"; deal_status: "orcamento" | "fechado" | "perdido";
impl_payment_condition: "À vista" | "1+1" | "1+2"; impl_payment_condition: "À vista" | "1+1" | "1+2";
client_registration_id: string | null; client_registration_id: string | null;
sales_rep_name: string | null;
} }
export interface ListQuotesFilter { export interface ListQuotesFilter {
@@ -97,6 +111,7 @@ export interface CreateQuoteInput {
approvalStatus: "none" | "pending" | "approved" | "rejected"; approvalStatus: "none" | "pending" | "approved" | "rejected";
implPaymentCondition: string; implPaymentCondition: string;
notes?: string | null; notes?: string | null;
salesRepName?: string | null;
createdBy?: string | null; createdBy?: string | null;
} }
@@ -106,8 +121,8 @@ export async function createQuote(input: CreateQuoteInput): Promise<Quote> {
reseller_id, client_name, client_company, client_email, client_phone, reseller_id, client_name, client_company, client_email, client_phone,
contract_period, fidelity_period, items, implementation_fee, impl_geral, contract_period, fidelity_period, items, implementation_fee, impl_geral,
monthly_total, contract_total, proposed_monthly_total, approval_status, monthly_total, contract_total, proposed_monthly_total, approval_status,
impl_payment_condition, notes, created_by impl_payment_condition, notes, sales_rep_name, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17) ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18)
RETURNING *`, RETURNING *`,
[ [
input.resellerId, input.resellerId,
@@ -126,6 +141,7 @@ export async function createQuote(input: CreateQuoteInput): Promise<Quote> {
input.approvalStatus, input.approvalStatus,
input.implPaymentCondition, input.implPaymentCondition,
input.notes ?? null, input.notes ?? null,
input.salesRepName ?? null,
input.createdBy ?? null, input.createdBy ?? null,
], ],
); );
@@ -167,6 +183,7 @@ export interface UpdateQuoteInput {
approvalNotes?: string | null; approvalNotes?: string | null;
implPaymentCondition?: string; implPaymentCondition?: string;
notes?: string | null; notes?: string | null;
salesRepName?: string | null;
} }
export async function updateQuote(id: string, input: UpdateQuoteInput): Promise<Quote | null> { export async function updateQuote(id: string, input: UpdateQuoteInput): Promise<Quote | null> {
@@ -188,7 +205,8 @@ export async function updateQuote(id: string, input: UpdateQuoteInput): Promise<
approved_by_id = $15, approved_by_id = $15,
approval_notes = COALESCE($16, approval_notes), approval_notes = COALESCE($16, approval_notes),
impl_payment_condition = COALESCE($17, impl_payment_condition), impl_payment_condition = COALESCE($17, impl_payment_condition),
notes = COALESCE($18, notes) notes = COALESCE($18, notes),
sales_rep_name = COALESCE($19, sales_rep_name)
WHERE id = $1 WHERE id = $1
RETURNING *`, RETURNING *`,
[ [
@@ -210,6 +228,7 @@ export async function updateQuote(id: string, input: UpdateQuoteInput): Promise<
input.approvalNotes ?? null, input.approvalNotes ?? null,
input.implPaymentCondition ?? null, input.implPaymentCondition ?? null,
input.notes ?? null, input.notes ?? null,
input.salesRepName ?? null,
], ],
); );
return rows[0] ?? null; return rows[0] ?? null;

View File

@@ -1,2 +1,12 @@
export const DOMAIN_SHARED_PACKAGE_VERSION = "0.1.0"; export const DOMAIN_SHARED_PACKAGE_VERSION = "0.1.0";
export { onlyDigits, isValidCPF, isValidCNPJ } from "./validators/br-documents.js"; export { onlyDigits, isValidCPF, isValidCNPJ, formatCPF, formatCNPJ } from "./validators/br-documents.js";
export {
SERVICE_TYPES,
EXCLUSIVE_SERVICE_TYPE,
TARIFF_TYPES,
TARIFF_DESCRIPTIONS,
TARIFF_EXTRA_NOTES,
emptyTariffRates,
getTariffTypesToShow,
} from "./telecom/service-types.js";
export type { ServiceType, TariffType, TariffRate, TariffRates } from "./telecom/service-types.js";

View File

@@ -0,0 +1,56 @@
/**
* Recorte regulatório RGC (Resolução Anatel nº 765/2023) — ported
* verbatim from the legacy's src/lib/serviceTypes.js e src/lib/
* tariffTypes.js. Compartilhado entre packages/database (tipagem de
* products.service_type/tariff_rates), apps/api (validação de DTO +
* template do PDF de oferta ANATEL) e apps/core-web (formulário de
* produto).
*/
/** Únicos 4 valores válidos no legado — sem CHECK adicional além destes. */
export const SERVICE_TYPES = ["STFC", "Serviço PABX", "SVA (locação)", "Serviço software"] as const;
export type ServiceType = (typeof SERVICE_TYPES)[number];
/** Usado para separar itens de software (SaaS) do restante de uma oferta — eden.md/legado. */
export const EXCLUSIVE_SERVICE_TYPE: ServiceType = "Serviço software";
export const TARIFF_TYPES = ["LC", "LDN", "VC1", "VC2", "VC3", "LDI"] as const;
export type TariffType = (typeof TARIFF_TYPES)[number];
export interface TariffRate {
normal: number;
reduced: number;
}
export type TariffRates = Record<TariffType, TariffRate>;
export function emptyTariffRates(): TariffRates {
return {
LC: { normal: 0, reduced: 0 },
LDN: { normal: 0, reduced: 0 },
VC1: { normal: 0, reduced: 0 },
VC2: { normal: 0, reduced: 0 },
VC3: { normal: 0, reduced: 0 },
LDI: { normal: 0, reduced: 0 },
};
}
/** LDI sempre existe no objeto (emptyTariffRates), mas só é EXIBIDO quando has_ldi=true. */
export function getTariffTypesToShow(hasLdi: boolean): readonly TariffType[] {
return hasLdi ? TARIFF_TYPES : TARIFF_TYPES.filter((t) => t !== "LDI");
}
/** Texto de legenda, verbatim do legado (src/lib/tariffTypes.js) — conteúdo regulatório, não parafrasear. */
export const TARIFF_DESCRIPTIONS: Record<TariffType, string> = {
LC: "Chamadas entre linhas fixas locais que estiverem na mesma área local (mesmo DDD).",
LDN: "Chamadas entre linhas fixas de longa distância que estiverem em áreas locais diferentes (DDD diferente).",
VC1: "Chamadas para linhas móveis que estiverem na mesma área local (mesmo DDD).",
VC2: "Chamadas para linhas móveis que estiverem em áreas locais diferentes, sendo o primeiro dígito do DDD dos dois telefones iguais e o segundo dígito diferente.",
VC3: "Chamadas para linhas móveis que estiverem em áreas locais diferentes, sendo o primeiro dígito do DDD dos dois telefones diferentes.",
LDI: "Chamadas de longa distância internacional.",
};
export const TARIFF_EXTRA_NOTES: readonly string[] = [
"Normal / Reduzido: as tarifas desta OFERTA são idênticas nos horários normal e reduzido.",
"Modalidade: Pós-pago.",
];

View File

@@ -48,3 +48,17 @@ export function isValidCNPJ(value: string): boolean {
checkDigit(12, WEIGHTS_12) === digits[12] && checkDigit(13, WEIGHTS_13) === digits[13] checkDigit(12, WEIGHTS_12) === digits[12] && checkDigit(13, WEIGHTS_13) === digits[13]
); );
} }
/** Display formatting only (000.000.000-00) — doesn't validate. Returns the raw input if it isn't 11 digits. */
export function formatCPF(value: string): string {
const digits = onlyDigits(value);
if (digits.length !== 11) return value;
return digits.replace(/(\d{3})(\d{3})(\d{3})(\d{2})/, "$1.$2.$3-$4");
}
/** Display formatting only (00.000.000/0000-00) — doesn't validate. Returns the raw input if it isn't 14 digits. */
export function formatCNPJ(value: string): string {
const digits = onlyDigits(value);
if (digits.length !== 14) return value;
return digits.replace(/(\d{2})(\d{3})(\d{3})(\d{4})(\d{2})/, "$1.$2.$3/$4-$5");
}

6
pnpm-lock.yaml generated
View File

@@ -105,6 +105,9 @@ importers:
apps/core-web: apps/core-web:
dependencies: dependencies:
'@eden/domain-shared':
specifier: workspace:*
version: link:../../packages/domain-shared
'@eden/ui': '@eden/ui':
specifier: workspace:* specifier: workspace:*
version: link:../../packages/ui version: link:../../packages/ui
@@ -266,6 +269,9 @@ importers:
packages/database: packages/database:
dependencies: dependencies:
'@eden/domain-shared':
specifier: workspace:*
version: link:../domain-shared
pg: pg:
specifier: ^8.13.1 specifier: ^8.13.1
version: 8.23.0 version: 8.23.0