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

View File

@@ -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;
}

View File

@@ -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<TariffRates> | undefined): TariffRates | undefined {
if (!partial) return undefined;
return { ...emptyTariffRates(), ...partial };
}
@Injectable()
export class ProductsService {
list(includeInactive: boolean): Promise<Product[]> {
@@ -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");