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

@@ -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"
},
"dependencies": {
"@eden/domain-shared": "workspace:*",
"pg": "^8.13.1"
},
"devDependencies": {

View File

@@ -1,4 +1,5 @@
import { query } from "../pool.js";
import type { ServiceType, TariffRates } from "@eden/domain-shared";
export interface Product {
id: string;
@@ -25,6 +26,11 @@ export interface Product {
price_36: string | null;
price_48: string | null;
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[]> {
@@ -61,6 +67,11 @@ export interface CreateProductInput {
price36?: number | null;
price48?: number | null;
implUnitPrice?: number;
serviceType?: ServiceType;
metered?: boolean;
minutesAllowance?: number | null;
tariffRates?: TariffRates;
hasLdi?: boolean;
createdBy?: string | null;
}
@@ -69,11 +80,13 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
`INSERT INTO products (
name, description, product_type, product_group, product_subgroup, brand, unit,
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 (
$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),
$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 *`,
[
@@ -96,6 +109,11 @@ export async function createProduct(input: CreateProductInput): Promise<Product>
input.price36 ?? null,
input.price48 ?? 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,
],
);
@@ -127,7 +145,12 @@ export async function updateProduct(id: string, input: UpdateProductInput): Prom
price_24 = COALESCE($17, price_24),
price_36 = COALESCE($18, price_36),
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
RETURNING *`,
[
@@ -151,6 +174,11 @@ export async function updateProduct(id: string, input: UpdateProductInput): Prom
input.price36 ?? null,
input.price48 ?? 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;

View File

@@ -1,4 +1,5 @@
import { query } from "../pool.js";
import type { ServiceType, TariffRates } from "@eden/domain-shared";
export interface QuoteItem {
productId: string;
@@ -13,6 +14,18 @@ export interface QuoteItem {
price24: number | null;
price36: 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 {
@@ -41,6 +54,7 @@ export interface Quote {
deal_status: "orcamento" | "fechado" | "perdido";
impl_payment_condition: "À vista" | "1+1" | "1+2";
client_registration_id: string | null;
sales_rep_name: string | null;
}
export interface ListQuotesFilter {
@@ -97,6 +111,7 @@ export interface CreateQuoteInput {
approvalStatus: "none" | "pending" | "approved" | "rejected";
implPaymentCondition: string;
notes?: string | null;
salesRepName?: 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,
contract_period, fidelity_period, items, implementation_fee, impl_geral,
monthly_total, contract_total, proposed_monthly_total, approval_status,
impl_payment_condition, notes, created_by
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17)
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,$18)
RETURNING *`,
[
input.resellerId,
@@ -126,6 +141,7 @@ export async function createQuote(input: CreateQuoteInput): Promise<Quote> {
input.approvalStatus,
input.implPaymentCondition,
input.notes ?? null,
input.salesRepName ?? null,
input.createdBy ?? null,
],
);
@@ -167,6 +183,7 @@ export interface UpdateQuoteInput {
approvalNotes?: string | null;
implPaymentCondition?: string;
notes?: string | null;
salesRepName?: string | 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,
approval_notes = COALESCE($16, approval_notes),
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
RETURNING *`,
[
@@ -210,6 +228,7 @@ export async function updateQuote(id: string, input: UpdateQuoteInput): Promise<
input.approvalNotes ?? null,
input.implPaymentCondition ?? null,
input.notes ?? null,
input.salesRepName ?? null,
],
);
return rows[0] ?? null;

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");
}