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,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;