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,2 +1,12 @@
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]
);
}
/** 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");
}