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

@@ -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 `<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. */
export function extractTemplateVariables(plainText: string): string[] {
const found = new Set<string>();

View File

@@ -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<TariffRates>;
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<TariffRates>;
hasLdi?: boolean;
}
/** Matches `UpdateProductDto`: everything optional. */

View File

@@ -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<TariffRates>;
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<Quote> {
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);
}