/** * 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 `
${renderHeader(quote)} ${renderClientBlock(clientLabel, clientDoc, quote)} ${renderPlanBox(quote, fidelityPeriod)} ${renderSavingsBox(savings)} ${renderItemsTable(items, pricing)} ${stfc ? `

A tecnologia utilizada para entrega das chamadas do serviço STFC ofertado é VoIP.

` : ""} ${meteredItems.length > 0 ? renderTariffSection(meteredItems) : ""} ${pricing.hasSpecialPrice ? renderSpecialConditionBox(quote, pricing) : ""} ${pricing.totalImpl > 0 ? renderImplementationBox(pricing, quote) : ""}
${stfc ? renderRgcBox({ codigoIdentificacao, nomeOferta, comercializacaoInicio, comercializacaoFim, fidelityPeriod, savings }) : "
"} ${renderFinancialSummaryBox({ quote, reseller, pricing, savings, nomeOferta })}
${quote.notes ? `
Observações: ${escapeHtml(quote.notes)}
` : ""} ${renderSignatures(quote, reseller)} ${renderFooter(comercializacaoInicio, comercializacaoFim)}
`; } function renderHeader(quote: Quote): string { const today = formatDateBR(new Date()); return `
Handix do Brasil
0800 048 0969
OFERTA
#${escapeHtml(quote.quote_number)}
${today}
`; } function renderClientBlock(clientLabel: string, clientDoc: string, quote: Quote): string { return `
Cliente
${escapeHtml(clientLabel)}
${clientDoc}
${quote.client_email ? `
${escapeHtml(quote.client_email)}
` : ""}
${escapeHtml(quote.client_phone)}
`; } 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 `
Plano contratado
${periodLabel}
${reduced ? `Fidelidade reduzida: ${fidelityPeriod} meses (aprovada)` : ""}
`; } function renderSavingsBox(savings: ReturnType): string { return `
Economia Mensal
${money(savings.totalEconomiaMensal)} /mês
`; } function renderItemsTable(items: QuoteItem[], pricing: ReturnType): 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 ` ${showSpecial ? "" : ""} ${showSpecial ? "" : ""} ${showSpecial ? "" : ""} ${rows}
${escapeHtml(serviceType)}
ItemQtdS/F Unit.Preço Unit. Desc.Fid./unTot.Desc.Fid.Desc.Esp./unTot.Desc.Esp.Tot.Desc.Preço Final/unTotal Contr.Total Final
Subtotal: ${money(subtotal)}
`; }) .join(""); const total = round2(items.reduce((sum, item) => sum + item.total, 0)); return `
${groupTables}
Total Geral da Oferta: ${money(total)}
`; } function renderItemRow(item: QuoteItem, pricing: ReturnType, 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 = `${money(specialUnit)}${money(itemSpecialDiscTotal)}`; } return ` ${escapeHtml(item.productName)} ${item.quantity} ${money(semFidUnit)} ${money(item.unitPrice)} ${money(descFidUnit)} ${money(totDescFid)} ${specialCells} ${money(totDesc)} ${showSpecial ? `${money(finalUnit)}` : ""} ${money(item.total)} ${showSpecial ? `${money(totalFinal)}` : ""} `; } 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 `${type}${money(rate.normal)}${money(rate.reduced)}`; }) .join(""); const legend = types.map((type) => `
  • ${type}: ${TARIFF_DESCRIPTIONS[type]}
  • `).join(""); const franquia = item.minutesAllowance != null ? `
    Franquia de minutos: ${item.minutesAllowance}
    ` : ""; return `
    ${escapeHtml(item.productName)}
    ${franquia} ${rows}
    TABELA DE TARIFAÇÃO (valor por minuto)
    TipoNormalReduzido
      ${legend}
    `; }) .join(""); const notes = TARIFF_EXTRA_NOTES.map((note) => `
  • ${escapeHtml(note)}
  • `).join(""); return `
    ${tables}
      ${notes}
    `; } function renderSpecialConditionBox(quote: Quote, pricing: ReturnType): 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 `
    Condição Especial ${statusLabel}
    Preço original: ${money(pricing.monthlyTotal)}
    ${pricing.isMarkup ? "Acréscimo" : "Desconto"}: ${money(Math.abs(pricing.diffMonthly))}
    Preço efetivo: ${money(pricing.effectiveMonthly)}
    ${quote.approval_notes ? `
    Observação da aprovação: ${escapeHtml(quote.approval_notes)}
    ` : ""}
    `; } function renderImplementationBox(pricing: ReturnType, quote: Quote): string { return `
    Total Implantação (único)
    ${money(pricing.totalImpl)} — condição: ${escapeHtml(quote.impl_payment_condition)}
    `; } function renderRgcBox(fields: { codigoIdentificacao: string; nomeOferta: string; comercializacaoInicio: string; comercializacaoFim: string; fidelityPeriod: number; savings: ReturnType; }): 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 `
    Conformidade RGC — Resolução Anatel nº 765/2023 (recorte STFC)
    ${rows.map(([label, value]) => ``).join("")}
    ${label}${value}
    `; } function renderFinancialSummaryBox(params: { quote: Quote; reseller: Reseller; pricing: ReturnType; savings: ReturnType; 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 `
    Resumo Financeiro
    ${rows.map(([label, value]) => ``).join("")}
    ${label}${value}
    `; } function renderSignatures(quote: Quote, reseller: Reseller): string { const salesRep = quote.sales_rep_name ? escapeHtml(quote.sales_rep_name) : "Responsável Comercial"; return `
    ${salesRep}
    ${escapeHtml(reseller.name)}
    Revendedor autorizado HANDIX
    ${escapeHtml(quote.client_name)}
    Cliente
    `; } function renderFooter(comercializacaoInicio: string, comercializacaoFim: string): string { return ` `; } function groupByServiceType(items: QuoteItem[]): Map { const map = new Map(); 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 `${escapeHtml(text)}`; } 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, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'"); } // 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; } `; }