From ccac9363720267bc3f0b07869052c05e6d10a3c9 Mon Sep 17 00:00:00 2001 From: "Matheus (Handix)" Date: Fri, 4 Sep 2026 10:33:33 -0300 Subject: [PATCH] feat(quotes): anexo de conformidade ANATEL/RGC no PDF de oferta MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01BcWWuKfykGtUweXmHD6cKx --- .../src/products/dto/create-product.dto.ts | 16 +- apps/api/src/products/dto/tariff-rate.dto.ts | 17 + apps/api/src/products/products.service.ts | 17 + apps/api/src/quotes/anatel-offer-template.ts | 517 ++++++++++++++++++ apps/api/src/quotes/dto/create-quote.dto.ts | 2 + apps/api/src/quotes/dto/update-quote.dto.ts | 1 + apps/api/src/quotes/quotes.controller.ts | 17 +- apps/api/src/quotes/quotes.service.ts | 47 ++ apps/api/test/anatel-pdf.spec.ts | 172 ++++++ apps/core-web/package.json | 1 + apps/core-web/src/lib/api/documents.ts | 36 +- apps/core-web/src/lib/api/products.ts | 14 + apps/core-web/src/lib/api/quotes.ts | 34 +- .../routes/documents/GenerationsListPage.tsx | 37 +- .../src/routes/products/ProductFormPage.tsx | 129 ++++- .../src/routes/quotes/QuoteDetailPage.tsx | 34 +- .../src/routes/quotes/QuoteFormPage.tsx | 9 + docs/progress.md | 32 ++ ...525270647_products-service-type-tariff.cjs | 61 +++ packages/database/package.json | 1 + .../database/src/repositories/products.ts | 34 +- packages/database/src/repositories/quotes.ts | 25 +- packages/domain-shared/src/index.ts | 12 +- .../src/telecom/service-types.ts | 56 ++ .../src/validators/br-documents.ts | 14 + pnpm-lock.yaml | 6 + 26 files changed, 1321 insertions(+), 20 deletions(-) create mode 100644 apps/api/src/products/dto/tariff-rate.dto.ts create mode 100644 apps/api/src/quotes/anatel-offer-template.ts create mode 100644 apps/api/test/anatel-pdf.spec.ts create mode 100644 packages/database/migrations/1788525270647_products-service-type-tariff.cjs create mode 100644 packages/domain-shared/src/telecom/service-types.ts diff --git a/apps/api/src/products/dto/create-product.dto.ts b/apps/api/src/products/dto/create-product.dto.ts index 3a577d5..a450744 100644 --- a/apps/api/src/products/dto/create-product.dto.ts +++ b/apps/api/src/products/dto/create-product.dto.ts @@ -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 { @IsString() @@ -33,4 +36,15 @@ export class CreateProductDto { @IsOptional() @IsNumber() @Min(0) price36?: number; @IsOptional() @IsNumber() @Min(0) price48?: 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; } diff --git a/apps/api/src/products/dto/tariff-rate.dto.ts b/apps/api/src/products/dto/tariff-rate.dto.ts new file mode 100644 index 0000000..395bb90 --- /dev/null +++ b/apps/api/src/products/dto/tariff-rate.dto.ts @@ -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; +} diff --git a/apps/api/src/products/products.service.ts b/apps/api/src/products/products.service.ts index 4cf0a31..e94589c 100644 --- a/apps/api/src/products/products.service.ts +++ b/apps/api/src/products/products.service.ts @@ -1,8 +1,15 @@ import { Injectable, NotFoundException } from "@nestjs/common"; 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 { 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 | undefined): TariffRates | undefined { + if (!partial) return undefined; + return { ...emptyTariffRates(), ...partial }; +} + @Injectable() export class ProductsService { list(includeInactive: boolean): Promise { @@ -36,6 +43,11 @@ export class ProductsService { price36: dto.price36, price48: dto.price48, implUnitPrice: dto.implUnitPrice, + serviceType: dto.serviceType, + metered: dto.metered, + minutesAllowance: dto.minutesAllowance, + tariffRates: normalizeTariffRates(dto.tariffRates), + hasLdi: dto.hasLdi, createdBy: actorId, }); @@ -73,6 +85,11 @@ export class ProductsService { price36: dto.price36, price48: dto.price48, 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"); diff --git a/apps/api/src/quotes/anatel-offer-template.ts b/apps/api/src/quotes/anatel-offer-template.ts new file mode 100644 index 0000000..201c873 --- /dev/null +++ b/apps/api/src/quotes/anatel-offer-template.ts @@ -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 ` + + + + + + + +
+ ${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 ` +
+ +
${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; } + `; +} diff --git a/apps/api/src/quotes/dto/create-quote.dto.ts b/apps/api/src/quotes/dto/create-quote.dto.ts index 1f1aff3..43e3042 100644 --- a/apps/api/src/quotes/dto/create-quote.dto.ts +++ b/apps/api/src/quotes/dto/create-quote.dto.ts @@ -50,4 +50,6 @@ export class CreateQuoteDto { @IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: 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; } diff --git a/apps/api/src/quotes/dto/update-quote.dto.ts b/apps/api/src/quotes/dto/update-quote.dto.ts index c5612e8..6db05c5 100644 --- a/apps/api/src/quotes/dto/update-quote.dto.ts +++ b/apps/api/src/quotes/dto/update-quote.dto.ts @@ -48,4 +48,5 @@ export class UpdateQuoteDto { @IsOptional() @IsIn(["À vista", "1+1", "1+2"]) implPaymentCondition?: string; @IsOptional() @IsString() @MinLength(0) notes?: string; + @IsOptional() @IsString() salesRepName?: string; } diff --git a/apps/api/src/quotes/quotes.controller.ts b/apps/api/src/quotes/quotes.controller.ts index d336f8e..afda85c 100644 --- a/apps/api/src/quotes/quotes.controller.ts +++ b/apps/api/src/quotes/quotes.controller.ts @@ -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 { QuotesService } from "./quotes.service.js"; import { CreateQuoteDto } from "./dto/create-quote.dto.js"; @@ -43,6 +44,20 @@ export class QuotesController { 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") @RequirePermission("quotes", "edit") update( diff --git a/apps/api/src/quotes/quotes.service.ts b/apps/api/src/quotes/quotes.service.ts index 0803af2..364b8b5 100644 --- a/apps/api/src/quotes/quotes.service.ts +++ b/apps/api/src/quotes/quotes.service.ts @@ -7,11 +7,14 @@ import { } from "@nestjs/common"; import { auditRepo, + clientRegistrationsRepo, productsRepo, quotesRepo, + resellersRepo, type Quote, type QuoteItem, } from "@eden/database"; +import { renderPdf } from "@eden/integrations"; import type { EdenAuthenticatedUser } from "../auth/jwt-auth.guard.js"; import { maxGrantedScope } from "../auth/permissions.js"; import { @@ -20,6 +23,7 @@ import { getAvailablePaymentConditions, resolveApprovalStatus, } from "./pricing.js"; +import { buildAnatelOfferHtml, hasStfcItem } from "./anatel-offer-template.js"; import type { CreateQuoteDto } from "./dto/create-quote.dto.js"; import type { UpdateQuoteDto } from "./dto/update-quote.dto.js"; import type { QuoteItemInputDto } from "./dto/quote-item-input.dto.js"; @@ -51,6 +55,40 @@ export class QuotesService { 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 { + 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 { const items = await this.buildItems(dto.items, dto.contractPeriod); const proposedMonthlyTotal = dto.proposedMonthlyTotal ?? null; @@ -93,6 +131,7 @@ export class QuotesService { approvalStatus, implPaymentCondition, notes: dto.notes ?? null, + salesRepName: dto.salesRepName ?? null, createdBy: user.id, }); @@ -166,6 +205,7 @@ export class QuotesService { approvedById, implPaymentCondition, notes: dto.notes, + salesRepName: dto.salesRepName, }); 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, price36: product.price_36 != null ? Number(product.price_36) : 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; diff --git a/apps/api/test/anatel-pdf.spec.ts b/apps/api/test/anatel-pdf.spec.ts new file mode 100644 index 0000000..aa525ae --- /dev/null +++ b/apps/api/test/anatel-pdf.spec.ts @@ -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"); + }); +}); diff --git a/apps/core-web/package.json b/apps/core-web/package.json index dd2bdc1..4bd7b46 100644 --- a/apps/core-web/package.json +++ b/apps/core-web/package.json @@ -11,6 +11,7 @@ "test": "echo 'no tests yet'" }, "dependencies": { + "@eden/domain-shared": "workspace:*", "@eden/ui": "workspace:*", "react": "^18.3.1", "react-dom": "^18.3.1", diff --git a/apps/core-web/src/lib/api/documents.ts b/apps/core-web/src/lib/api/documents.ts index 4001f38..f098885 100644 --- a/apps/core-web/src/lib/api/documents.ts +++ b/apps/core-web/src/lib/api/documents.ts @@ -2,11 +2,12 @@ * 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). * - * This leva has NO real visual editor and NO real PDF: `editorContent` is a - * simplified JSONB with a single root-level `plainText` string carrying - * `{{variavel}}` placeholders (no rich node tree), and "generation" just - * merges variables into `renderedHtml` — `pdf_s3_key`/`pdf_sha256`/ - * `pdf_size_bytes` are always `null` here. Don't build UI implying more. + * This leva has NO real visual editor: `editorContent` is a simplified + * JSONB with a single root-level `plainText` string carrying + * `{{variavel}}` placeholders (no rich node tree). PDF generation itself + * IS real (Chromium via packages/integrations/src/pdf/renderer.ts) — + * `pdf_s3_key`/`pdf_sha256`/`pdf_size_bytes` are populated on every + * `status: 'final'` generation. * * Like Contracts/Resellers, template/version/generation responses are * 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, * 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 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 `` 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 { + 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. */ export function extractTemplateVariables(plainText: string): string[] { const found = new Set(); diff --git a/apps/core-web/src/lib/api/products.ts b/apps/core-web/src/lib/api/products.ts index 6b98607..7ec1945 100644 --- a/apps/core-web/src/lib/api/products.ts +++ b/apps/core-web/src/lib/api/products.ts @@ -4,6 +4,9 @@ * truth — shapes below are mirrored, not invented). */ 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. @@ -36,6 +39,12 @@ export interface Product { price_36: string | null; price_48: string | null; 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; + has_ldi: boolean; } /** Request body — camelCase, matches `CreateProductDto` exactly. */ @@ -59,6 +68,11 @@ export interface CreateProductInput { price36?: number; price48?: number; implUnitPrice?: number; + serviceType?: ServiceType; + metered?: boolean; + minutesAllowance?: number; + tariffRates?: Partial; + hasLdi?: boolean; } /** Matches `UpdateProductDto`: everything optional. */ diff --git a/apps/core-web/src/lib/api/quotes.ts b/apps/core-web/src/lib/api/quotes.ts index 7ca84b9..8991613 100644 --- a/apps/core-web/src/lib/api/quotes.ts +++ b/apps/core-web/src/lib/api/quotes.ts @@ -11,7 +11,8 @@ * `contract_total`, `proposed_monthly_total`) come back as NUMERIC-typed * 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 type ContractPeriod = (typeof CONTRACT_PERIODS)[number]; @@ -35,6 +36,12 @@ export interface QuoteItem { price24: number | null; price36: 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; + hasLdi: boolean; } export interface Quote { @@ -63,6 +70,8 @@ export interface Quote { deal_status: DealStatus; impl_payment_condition: ImplPaymentCondition; 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 { @@ -83,6 +92,7 @@ export interface CreateQuoteInput { proposedMonthlyTotal?: number; implPaymentCondition?: ImplPaymentCondition; notes?: string; + salesRepName?: string; } export interface DecideApprovalInput { @@ -122,3 +132,25 @@ export function decideQuoteApproval( export function closeQuoteDeal(id: string, input: CloseDealInput, accessToken: string): Promise { return apiRequest(`/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 { + 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); +} diff --git a/apps/core-web/src/routes/documents/GenerationsListPage.tsx b/apps/core-web/src/routes/documents/GenerationsListPage.tsx index ee6b0b4..9a32ef6 100644 --- a/apps/core-web/src/routes/documents/GenerationsListPage.tsx +++ b/apps/core-web/src/routes/documents/GenerationsListPage.tsx @@ -1,10 +1,10 @@ import { useEffect, useMemo, useState } from "react"; 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 { useAuth } from "../../lib/auth/AuthContext.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 { listClientRegistrations } 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([]); const [resellers, setResellers] = useState([]); const [error, setError] = useState(null); + const [downloadingId, setDownloadingId] = useState(null); useEffect(() => { if (!accessToken) return; @@ -61,6 +62,21 @@ export function GenerationsListPage() { const clientById = useMemo(() => new Map(clientRegistrations.map((c) => [c.id, c])), [clientRegistrations]); 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> = [ { key: "template", @@ -115,6 +131,23 @@ export function GenerationsListPage() { header: "Data", render: (generation) => formatDateTimeBR(generation.created_at), }, + { + key: "pdf", + header: "PDF", + render: (generation) => + generation.pdf_s3_key ? ( + + ) : ( + "—" + ), + }, ]; return ( diff --git a/apps/core-web/src/routes/products/ProductFormPage.tsx b/apps/core-web/src/routes/products/ProductFormPage.tsx index 955f5cd..67ffe0c 100644 --- a/apps/core-web/src/routes/products/ProductFormPage.tsx +++ b/apps/core-web/src/routes/products/ProductFormPage.tsx @@ -1,7 +1,9 @@ import { useEffect, useState } from "react"; import type { FormEvent } from "react"; 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 type { CreateProductInput } from "../../lib/api/products.js"; import { ApiError } from "../../lib/api/http.js"; @@ -59,6 +61,18 @@ interface ProductFormState { price36Cents: number; price48Cents: 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; +} + +function emptyTariffRatesCents(): ProductFormState["tariffRatesCents"] { + return Object.fromEntries(TARIFF_TYPES.map((t) => [t, { normal: 0, reduced: 0 }])) as ProductFormState["tariffRatesCents"]; } const EMPTY_FORM: ProductFormState = { @@ -81,6 +95,11 @@ const EMPTY_FORM: ProductFormState = { price36Cents: 0, price48Cents: 0, implUnitPriceCents: 0, + serviceType: "STFC", + metered: false, + minutesAllowance: "", + hasLdi: false, + tariffRatesCents: emptyTariffRatesCents(), }; function toOptional(value: string): string | undefined { @@ -126,6 +145,19 @@ export function ProductFormPage() { price36Cents: Math.round(Number(product.price_36 ?? 0) * 100), price48Cents: Math.round(Number(product.price_48 ?? 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) => { @@ -169,6 +201,16 @@ export function ProductFormPage() { price36: form.price36Cents / 100, price48: form.price48Cents / 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) { @@ -270,6 +312,91 @@ export function ProductFormPage() { + +

    + Usado no anexo de conformidade ANATEL do PDF de oferta — só relevante para produtos de telefonia (STFC) + faturados por minuto. +

    +
    + updateField("minutesAllowance", e.target.value.replace(/\D/g, ""))} + placeholder="Deixe em branco se não houver" + /> + + +
    + + {form.metered ? ( +
    + + + + + + + + + + {TARIFF_TYPES.filter((t) => t !== "LDI" || form.hasLdi).map((type) => ( + + + + + + ))} + +
    TipoNormalReduzido
    {type} + + updateField("tariffRatesCents", { + ...form.tariffRatesCents, + [type]: { ...form.tariffRatesCents[type], normal: v }, + }) + } + /> + + + updateField("tariffRatesCents", { + ...form.tariffRatesCents, + [type]: { ...form.tariffRatesCents[type], reduced: v }, + }) + } + /> +
    +
    + ) : null} +
    +
    {( diff --git a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx index 975f57a..d4fa8e1 100644 --- a/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx +++ b/apps/core-web/src/routes/quotes/QuoteDetailPage.tsx @@ -6,7 +6,7 @@ import { Alert, Badge, Button, Card, Input, PageHeader, Select } from "@eden/ui" import type { BadgeVariant } from "@eden/ui"; import { useAuth } from "../../lib/auth/AuthContext.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 { listResellers } from "../../lib/api/resellers.js"; import type { Reseller } from "../../lib/api/resellers.js"; @@ -58,6 +58,8 @@ export function QuoteDetailPage() { const [reseller, setReseller] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); + const [downloadingAnatel, setDownloadingAnatel] = useState(false); + const [anatelError, setAnatelError] = useState(null); const load = useCallback(() => { if (!id || !accessToken) return; @@ -76,6 +78,19 @@ export function QuoteDetailPage() { 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) { return (
    @@ -103,6 +118,16 @@ export function QuoteDetailPage() {
    {APPROVAL_LABEL[quote.approval_status]} {DEAL_LABEL[quote.deal_status]} + {hasStfcItem(quote.items) ? ( + + ) : null} {quote.deal_status === "fechado" ? ( + {anatelError ? ( + + {anatelError} + + ) : null} +
    @@ -137,6 +168,7 @@ export function QuoteDetailPage() { value={quote.fidelity_period != null ? `${quote.fidelity_period} meses` : "—"} /> + diff --git a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx index 1ac1f6e..75afdb3 100644 --- a/apps/core-web/src/routes/quotes/QuoteFormPage.tsx +++ b/apps/core-web/src/routes/quotes/QuoteFormPage.tsx @@ -56,6 +56,7 @@ export function QuoteFormPage() { const [proposedMonthlyTotal, setProposedMonthlyTotal] = useState(""); const [implPaymentCondition, setImplPaymentCondition] = useState(""); const [notes, setNotes] = useState(""); + const [salesRepName, setSalesRepName] = useState(""); const [submitting, setSubmitting] = useState(false); const [error, setError] = useState(null); @@ -134,6 +135,7 @@ export function QuoteFormPage() { proposedMonthlyTotal: proposedMonthlyTotal ? Number(proposedMonthlyTotal) : undefined, implPaymentCondition: implPaymentCondition || undefined, notes: notes || undefined, + salesRepName: salesRepName || undefined, }, accessToken, ); @@ -346,6 +348,13 @@ export function QuoteFormPage() {
    + setSalesRepName(e.target.value)} + hint='Impresso no PDF de oferta — cai para "Responsável Comercial" quando ausente.' + /> +