diff --git a/AI-HISTORY.md b/AI-HISTORY.md index 00aa3d6..3a8e488 100644 --- a/AI-HISTORY.md +++ b/AI-HISTORY.md @@ -16,6 +16,21 @@ Ordem cronológica — **mais recente no topo**. --- +### 2026-08-11 03:00 — Radar de Conformidade: validação por CNPJ (1.2.0) + +- **Tempo:** ~30 min +- **Prompt:** "em documentos, crie uma funcionalidade de validar conformidade" (spec: Obsidian + 18 - Radar de Conformidade — metodologia do score + fontes de dados testadas em 11/08) +- **Mudanças:** módulo `radar`: `RadarLookupService` (Receita em cadeia BrasilAPI→minhareceita→CNPJá, + QSA reduzido a contagem por LGPD; repasse da ANP com decode ISO-8859-1, falha degrada para + `available:false`); `RadarScoreService` (motor de 17 itens/7 esferas, R1–R7, teto por gargalo, + três números — espelho do frontend); entity+migration `radar_assessment` (única por + programa+loja+CNPJ, respostas em jsonb, diagnóstico sempre recalculado); controller + `/radar/company|anp|assessments`. 21 testes novos (83 no total). +- **Artefatos:** branch `feat/compliance-radar` + +--- + ### 2026-08-07 02:45 — Prazo de renovação dinâmico por órgão (Onda 1.2) - **Tempo:** ~30 min diff --git a/CHANGELOG.md b/CHANGELOG.md index 0f476e5..b4002be 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,19 @@ Formato baseado em [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); versionamento semântico. +## [1.2.0] - 2026-08-11 + +### Added +- **Radar de Conformidade** (`/radar/*`): validação de conformidade por CNPJ. `GET /radar/company/:cnpj` + consulta a Receita Federal em cadeia de fallback (BrasilAPI → minhareceita.org → CNPJá Open) e + normaliza para um shape único — o QSA é reduzido à contagem (LGPD). `GET /radar/anp/:cnpj` repassa a + API de revendedores da ANP (sem CORS na origem) com decode ISO-8859-1; falha degrada para + `available: false`, nunca erro. `GET/PUT /radar/assessments/:cnpj` guarda a autoavaliação dos 17 + itens por (programa, loja, CNPJ) e devolve o diagnóstico recalculado: score declarado, score + verificado, índice de confiança, faixa A–E, teto por gargalo (documento impeditivo), pendências + ordenadas por ganho e calendário de vencimentos. Motor espelho do frontend (regras R1–R7 da + metodologia). Migration `radar_assessment` com up/down simétricos e idempotentes. + ## [1.1.0] - 2026-08-07 ### Added diff --git a/package.json b/package.json index f589225..49578d5 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "documents", - "version": "1.1.0", + "version": "1.2.0", "description": "Serviço de documentos e licenças dos postos (catálogo legal por UF, documentos com versões, conformidade e análise de upload)", "author": "ClubPetro", "private": true, diff --git a/src/app.module.ts b/src/app.module.ts index 7a33b7b..1544a0c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,8 @@ import { StorageModule } from './modules/common/storage/storage.module'; import { ComplianceModule } from './modules/compliance/compliance.module'; import { StoreDocumentException } from './modules/document/document.exception'; import { DocumentModule } from './modules/document/document.module'; +import { RadarException } from './modules/radar/radar.exception'; +import { RadarModule } from './modules/radar/radar.module'; import { config } from './ormconfig'; @Module({ @@ -19,7 +21,7 @@ import { config } from './ormconfig'; ConfigModule.forRoot(), TypeOrmModule.forRoot({ ...config, poolSize: 20 }), MappedExceptionModule.forRoot( - [DocumentTypeException, StoreDocumentException], + [DocumentTypeException, StoreDocumentException, RadarException], { prefix: 'DOC' }, ), StorageModule, @@ -27,6 +29,7 @@ import { config } from './ormconfig'; DocumentModule, ComplianceModule, AlertsModule, + RadarModule, ], controllers: [AppController], providers: [AppService], diff --git a/src/migrations/1786752000000-migration.ts b/src/migrations/1786752000000-migration.ts new file mode 100644 index 0000000..d473c4b --- /dev/null +++ b/src/migrations/1786752000000-migration.ts @@ -0,0 +1,31 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +/** + * Autoavaliações do Radar de Conformidade — respostas dos 17 itens por + * (programa, loja, CNPJ). O diagnóstico não é persistido: é recalculado a + * cada leitura, para regra nova de score valer imediatamente. + */ +export class migration1786752000000 implements MigrationInterface { + name = 'migration1786752000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS public.radar_assessment ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + create_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP, + update_date timestamp NOT NULL DEFAULT LOCALTIMESTAMP, + "programId" uuid NOT NULL, + "storeId" uuid NOT NULL, + "cnpj" varchar(14) NOT NULL, + "mode" varchar(16) NOT NULL DEFAULT 'own', + "answers" jsonb NOT NULL DEFAULT '{}'::jsonb + ); + CREATE UNIQUE INDEX IF NOT EXISTS uq_radar_assessment_tenant_cnpj + ON public.radar_assessment ("programId","storeId","cnpj"); + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS public.radar_assessment;`); + } +} diff --git a/src/modules/radar/__tests__/radarLookup.service.spec.ts b/src/modules/radar/__tests__/radarLookup.service.spec.ts new file mode 100644 index 0000000..f9b7d65 --- /dev/null +++ b/src/modules/radar/__tests__/radarLookup.service.spec.ts @@ -0,0 +1,151 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { mappedExceptionMock } from '../../common/__tests__/mock/repository.mock'; +import { RadarException } from '../radar.exception'; +import { + RadarLookupService, + isValidCnpj, + onlyDigits, +} from '../radarLookup.service'; + +// CNPJ real de exemplo (Banco do Brasil) — só para o dígito verificador. +const VALID_CNPJ = '00000000000191'; + +const rfbPayload = { + cnpj: '00.000.000/0001-91', + razao_social: 'POSTO EXEMPLO LTDA', + descricao_situacao_cadastral: 'ATIVA', + cnae_fiscal: 4731800, + cnae_fiscal_descricao: 'Comércio varejista de combustíveis', + cnaes_secundarios: [{ codigo: 4712100, descricao: 'Minimercado' }], + uf: 'MG', + municipio: 'UBERLANDIA', + qsa: [{ nome_socio: 'NOME QUE NAO PODE VAZAR' }], +}; + +const jsonResponse = (body: unknown, status = 200) => + ({ + ok: status >= 200 && status < 300, + status, + json: async () => body, + arrayBuffer: async () => new TextEncoder().encode(JSON.stringify(body)), + }) as any; + +const setup = () => { + const exception = mappedExceptionMock(new RadarException()); + const service = new RadarLookupService(exception as any); + return { service, exception }; +}; + +describe('RadarLookupService', () => { + afterEach(() => { + jest.restoreAllMocks(); + }); + + it('valida dígito verificador do CNPJ', () => { + expect(isValidCnpj(VALID_CNPJ)).toBe(true); + expect(isValidCnpj('00.000.000/0001-91')).toBe(true); + expect(isValidCnpj('11111111111111')).toBe(false); + expect(isValidCnpj('123')).toBe(false); + expect(onlyDigits('00.000.000/0001-91')).toBe(VALID_CNPJ); + }); + + it('normaliza o payload da BrasilAPI e não expõe o QSA (LGPD)', async () => { + const { service } = setup(); + global.fetch = jest.fn().mockResolvedValue(jsonResponse(rfbPayload)); + + const result = await service.lookupCompany(VALID_CNPJ); + + expect(result.sourceName).toContain('BrasilAPI'); + expect(result.identity.legalName).toBe('POSTO EXEMPLO LTDA'); + expect(result.identity.mainCnae).toBe(4731800); + expect(result.identity.uf).toBe('MG'); + // O QSA vira contagem — nome de sócio é dado pessoal e não sai daqui. + expect(result.identity.partnersCount).toBe(1); + expect(JSON.stringify(result)).not.toContain('NAO PODE VAZAR'); + }); + + it('cai para a minhareceita.org quando a BrasilAPI falha por rede', async () => { + const { service } = setup(); + global.fetch = jest + .fn() + .mockRejectedValueOnce(new Error('offline')) + .mockResolvedValueOnce(jsonResponse(rfbPayload)); + + const result = await service.lookupCompany(VALID_CNPJ); + + expect(result.sourceName).toContain('minhareceita'); + expect(global.fetch).toHaveBeenCalledTimes(2); + }); + + it('404 na BrasilAPI é resposta definitiva: CNPJ não existe', async () => { + const { service } = setup(); + global.fetch = jest.fn().mockResolvedValue(jsonResponse({}, 404)); + + await expect(service.lookupCompany(VALID_CNPJ)).rejects.toThrow( + 'CNPJ_NOT_FOUND', + ); + expect(global.fetch).toHaveBeenCalledTimes(1); + }); + + it('CNPJ inválido nem chega na rede', async () => { + const { service } = setup(); + global.fetch = jest.fn(); + + await expect(service.lookupCompany('11111111111111')).rejects.toThrow( + 'CNPJ_INVALID', + ); + expect(global.fetch).not.toHaveBeenCalled(); + }); + + it('sem nenhuma fonte de pé, falha com SOURCES_UNAVAILABLE', async () => { + const { service } = setup(); + global.fetch = jest.fn().mockRejectedValue(new Error('offline')); + + await expect(service.lookupCompany(VALID_CNPJ)).rejects.toThrow( + 'SOURCES_UNAVAILABLE', + ); + expect(global.fetch).toHaveBeenCalledTimes(3); + }); + + it('decodifica a resposta da ANP como ISO-8859-1', async () => { + const { service } = setup(); + const record = { + numeroAutorizacao: 'PB123', + bandeira: 'BANDEIRA AÇAÍ', + quantidadeBico: 8, + produtos: [{ nomeProduto: 'GASOLINA C', tancagem: 30 }], + }; + // Simula o upstream: bytes em latin-1, como a ANP devolve de verdade. + const latin1 = new Uint8Array( + JSON.stringify([record]) + .split('') + .map((char) => char.charCodeAt(0) & 0xff), + ); + global.fetch = jest.fn().mockResolvedValue({ + ok: true, + status: 200, + arrayBuffer: async () => latin1.buffer, + } as any); + + const result = await service.lookupAnp(VALID_CNPJ); + + expect(result.available).toBe(true); + expect(result.authorization?.flag).toBe('BANDEIRA AÇAÍ'); + expect(result.authorization?.authorizationNumber).toBe('PB123'); + expect(result.authorization?.nozzles).toBe(8); + expect(result.authorization?.products[0]).toEqual({ + name: 'GASOLINA C', + tankageM3: 30, + }); + }); + + it('falha na ANP degrada para não verificado, nunca derruba', async () => { + const { service } = setup(); + global.fetch = jest.fn().mockRejectedValue(new Error('sem proxy')); + + const result = await service.lookupAnp(VALID_CNPJ); + + expect(result.available).toBe(false); + expect(result.authorization).toBeNull(); + }); +}); diff --git a/src/modules/radar/__tests__/radarScore.service.spec.ts b/src/modules/radar/__tests__/radarScore.service.spec.ts new file mode 100644 index 0000000..9f9b365 --- /dev/null +++ b/src/modules/radar/__tests__/radarScore.service.spec.ts @@ -0,0 +1,192 @@ +import { addMonths, todayKey } from '../../common/utils/status.util'; +import { RADAR_ITEMS } from '../radarCatalog'; +import { + RadarAnswers, + RadarEvidence, + RadarItemState, +} from '../radar.types'; +import { RadarScoreService } from '../radarScore.service'; + +const TODAY = todayKey(); + +const futureDate = (days: number): string => { + const date = new Date(); + date.setDate(date.getDate() + days); + return todayKey(date); +}; + +/** Todos os itens em dia, com folga, evidência declarada. */ +const allValid = (evidence = RadarEvidence.Declared): RadarAnswers => + RADAR_ITEMS.reduce((acc, item) => { + acc[item.id] = { + state: RadarItemState.Valid, + evidence, + answeredAt: TODAY, + ...(item.hasValidity ? { expiryDate: futureDate(200) } : {}), + }; + return acc; + }, {} as RadarAnswers); + +describe('RadarScoreService', () => { + const service = new RadarScoreService(); + + it('os pesos do catálogo somam 100', () => { + const total = RADAR_ITEMS.reduce((acc, item) => acc + item.weight, 0); + expect(total).toBe(100); + }); + + it('tudo em dia com folga dá 100, faixa A', () => { + const result = service.compute(allValid()); + expect(result.score).toBe(100); + expect(result.band).toBe('A'); + expect(result.ceiling).toBeNull(); + expect(result.pendencies).toHaveLength(0); + }); + + it('sem respostas, tudo vale 0,25 e o teto de "não informado" trava em 79', () => { + const result = service.compute({}); + expect(result.score).toBe(25); + expect(result.band).toBe('E'); + // Os 4 impeditivos não informados aparecem como gargalo de teto 79. + expect(result.bottlenecks.every((b) => b.ceiling === 79)).toBe(true); + }); + + it('AVCB inexistente trava a nota em 39 mesmo com o resto perfeito (R2)', () => { + const answers = allValid(); + answers.bmb_avcb = { + state: RadarItemState.Missing, + evidence: RadarEvidence.Declared, + answeredAt: TODAY, + }; + const result = service.compute(answers); + expect(result.ceiling).toBe(39); + expect(result.score).toBeLessThanOrEqual(39); + expect(result.band).toBe('E'); + // O gargalo vem primeiro na ordem de ataque, antes de qualquer ganho. + expect(result.pendencies[0].id).toBe('bmb_avcb'); + expect(result.pendencies[0].isBottleneck).toBe(true); + }); + + it('impeditivo apenas protocolado trava em 59 — fiscalização não aceita protocolo', () => { + const answers = allValid(); + answers.amb_lo = { + state: RadarItemState.InProcess, + evidence: RadarEvidence.Declared, + answeredAt: TODAY, + }; + const result = service.compute(answers); + expect(result.ceiling).toBe(59); + }); + + it('impeditivo válido vencendo em 30 dias nunca deixa a nota na faixa A', () => { + const answers = allValid(); + answers.amb_lo = { + state: RadarItemState.Valid, + evidence: RadarEvidence.Declared, + answeredAt: TODAY, + expiryDate: futureDate(20), + }; + const result = service.compute(answers); + expect(result.ceiling).toBe(79); + expect(result.band).not.toBe('A'); + }); + + it('vencido sem data informada vale 0,30 — recém-vencido, nunca em dia', () => { + const answers = allValid(); + answers.sst_pgr = { + state: RadarItemState.Expired, + evidence: RadarEvidence.Declared, + answeredAt: TODAY, + }; + const result = service.compute(answers); + const item = result.pendencies.find((entry) => entry.id === 'sst_pgr'); + expect(item?.compliance).toBeCloseTo(0.3); + }); + + it('"não se aplica" sai do denominador com renormalização (R6)', () => { + const answers = allValid(); + answers.mun_sanitario = { + state: RadarItemState.NotApplicable, + evidence: RadarEvidence.Declared, + answeredAt: TODAY, + }; + const result = service.compute(answers); + // Sem o item, o resto continua 100% — nunca penaliza o que não incide. + expect(result.score).toBe(100); + expect( + result.pendencies.find((entry) => entry.id === 'mun_sanitario'), + ).toBeUndefined(); + }); + + it('evidência muda confiança e score verificado, nunca o declarado (R4)', () => { + const declared = service.compute(allValid(RadarEvidence.Declared)); + const verified = service.compute(allValid(RadarEvidence.Verified)); + expect(declared.score).toBe(verified.score); + expect(declared.confidence).toBe(35); + expect(verified.confidence).toBe(100); + expect(declared.verifiedScore).toBeLessThan(verified.verifiedScore); + }); + + it('declaração com mais de 180 dias vale metade na confiança (R5)', () => { + const fresh = service.compute(allValid()); + const stale = RADAR_ITEMS.reduce((acc, item) => { + acc[item.id] = { + state: RadarItemState.Valid, + evidence: RadarEvidence.Declared, + answeredAt: addMonths(TODAY, -8), + ...(item.hasValidity ? { expiryDate: futureDate(200) } : {}), + }; + return acc; + }, {} as RadarAnswers); + const result = service.compute(stale); + // Metade de 35 é 17,5 — o arredondamento pode cair para os dois lados. + expect( + Math.abs(result.confidence - fresh.confidence / 2), + ).toBeLessThanOrEqual(0.5); + }); + + it('bloqueio de identidade trava em 39 e aparece como gargalo próprio', () => { + const result = service.compute(allValid(), true); + expect(result.ceiling).toBe(39); + expect( + result.bottlenecks.find((entry) => entry.id === 'rfb'), + ).toBeDefined(); + }); + + it('fator temporal segue as rampas da R1', () => { + expect(service.temporalFactor(null)).toBe(1); + expect(service.temporalFactor(200)).toBe(1); + expect(service.temporalFactor(90)).toBe(1); + expect(service.temporalFactor(60)).toBeCloseTo(0.8); + expect(service.temporalFactor(30)).toBeCloseTo(0.6); + expect(service.temporalFactor(15)).toBeCloseTo(0.45); + expect(service.temporalFactor(0)).toBeCloseTo(0.3); + // Vencido decai exponencialmente com piso 0,05. + expect(service.temporalFactor(-10)).toBeLessThan(0.3); + expect(service.temporalFactor(-1000)).toBeCloseTo(0.05); + }); + + it('pendências são ordenadas pelo ganho real', () => { + const answers = allValid(); + // Dois não-impeditivos pendentes com pesos diferentes. + answers.amb_estanqueidade = { + state: RadarItemState.Missing, + evidence: RadarEvidence.Declared, + answeredAt: TODAY, + }; + answers.met_consumidor = { + state: RadarItemState.Missing, + evidence: RadarEvidence.Declared, + answeredAt: TODAY, + }; + const result = service.compute(answers); + const ids = result.pendencies.map((entry) => entry.id); + expect(ids.indexOf('amb_estanqueidade')).toBeLessThan( + ids.indexOf('met_consumidor'), + ); + const heavier = result.pendencies.find( + (entry) => entry.id === 'amb_estanqueidade', + ); + expect(heavier?.gain).toBeCloseTo(5); + }); +}); diff --git a/src/modules/radar/dto/request/radar.dto.ts b/src/modules/radar/dto/request/radar.dto.ts new file mode 100644 index 0000000..149ccac --- /dev/null +++ b/src/modules/radar/dto/request/radar.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { IsIn, IsNotEmpty, IsObject, IsString, Length } from 'class-validator'; + +import { TenantWithStoreDto } from '../../../common/dto/tenant.dto'; +import { RadarAnswers, RadarMode } from '../../radar.types'; + +export class CnpjParamDto { + @ApiProperty({ description: 'CNPJ, com ou sem pontuação' }) + @IsString() + @IsNotEmpty() + @Length(14, 18) + cnpj: string; +} + +export class SaveAssessmentDto extends TenantWithStoreDto { + @ApiProperty({ enum: ['own', 'thirdParty'] }) + @IsIn(['own', 'thirdParty']) + mode: RadarMode; + + /** + * Respostas por item (`{ [itemId]: { state, evidence, expiryDate?, ... } }`). + * Itens desconhecidos são ignorados pelo motor — o catálogo é a whitelist. + */ + @ApiProperty({ description: 'Respostas por item do catálogo' }) + @IsObject() + answers: RadarAnswers; +} diff --git a/src/modules/radar/radar.controller.ts b/src/modules/radar/radar.controller.ts new file mode 100644 index 0000000..259f449 --- /dev/null +++ b/src/modules/radar/radar.controller.ts @@ -0,0 +1,81 @@ +import { JwtAuthGuard } from '@clubpetrodev/authmodule'; +import { MappedExceptionFilter } from '@clubpetrodev/nestjs-mapped-exception'; +import { + Body, + Controller, + Get, + HttpStatus, + Param, + Put, + Query, + UseFilters, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger'; + +import { FiltersInterceptor } from '../../interceptors/filters.interceptor'; +import { TenantWithStoreDto } from '../common/dto/tenant.dto'; +import { CnpjParamDto, SaveAssessmentDto } from './dto/request/radar.dto'; +import { RadarLookupService } from './radarLookup.service'; +import { RadarService } from './radar.service'; + +/** + * Radar de Conformidade — validação de conformidade por CNPJ. + * + * `company` e `anp` são repasses de fonte pública (a ANP não tem CORS e + * responde em ISO-8859-1 — o navegador não consegue sozinho). As + * autoavaliações são por tenant; o diagnóstico é recalculado a cada + * leitura. + */ +@ApiTags('Radar de Conformidade') +@Controller('radar') +@UseFilters(MappedExceptionFilter) +@UseInterceptors(FiltersInterceptor) +@UseGuards(JwtAuthGuard) +export class RadarController { + constructor( + private readonly lookupService: RadarLookupService, + private readonly service: RadarService, + ) {} + + @Get('company/:cnpj') + @ApiBearerAuth() + @ApiResponse({ status: HttpStatus.OK }) + async getCompany(@Param() params: CnpjParamDto) { + return this.lookupService.lookupCompany(params.cnpj); + } + + @Get('anp/:cnpj') + @ApiBearerAuth() + @ApiResponse({ status: HttpStatus.OK }) + async getAnp(@Param() params: CnpjParamDto) { + return this.lookupService.lookupAnp(params.cnpj); + } + + @Get('assessments/:cnpj') + @ApiBearerAuth() + @ApiResponse({ status: HttpStatus.OK }) + async getAssessment( + @Param() params: CnpjParamDto, + @Query() filters: TenantWithStoreDto, + ) { + return this.service.getAssessment(filters, params.cnpj); + } + + @Put('assessments/:cnpj') + @ApiBearerAuth() + @ApiResponse({ status: HttpStatus.OK }) + async saveAssessment( + @Param() params: CnpjParamDto, + @Body() body: SaveAssessmentDto, + ) { + return this.service.saveAssessment({ + programId: body.programId, + storeId: body.storeId, + cnpj: params.cnpj, + mode: body.mode, + answers: body.answers, + }); + } +} diff --git a/src/modules/radar/radar.exception.ts b/src/modules/radar/radar.exception.ts new file mode 100644 index 0000000..3f925b8 --- /dev/null +++ b/src/modules/radar/radar.exception.ts @@ -0,0 +1,22 @@ +import { MappedExceptionItem } from '@clubpetrodev/nestjs-mapped-exception'; +import { HttpStatus } from '@nestjs/common'; + +export class RadarException { + CNPJ_INVALID: MappedExceptionItem = { + message: 'CNPJ inválido', + code: 1, + statusCode: HttpStatus.BAD_REQUEST, + }; + + CNPJ_NOT_FOUND: MappedExceptionItem = { + message: 'CNPJ não encontrado na base da Receita Federal', + code: 2, + statusCode: HttpStatus.NOT_FOUND, + }; + + SOURCES_UNAVAILABLE: MappedExceptionItem = { + message: 'Nenhuma fonte pública de CNPJ respondeu', + code: 3, + statusCode: HttpStatus.BAD_GATEWAY, + }; +} diff --git a/src/modules/radar/radar.module.ts b/src/modules/radar/radar.module.ts new file mode 100644 index 0000000..025943c --- /dev/null +++ b/src/modules/radar/radar.module.ts @@ -0,0 +1,21 @@ +import { AuthModule } from '@clubpetrodev/authmodule'; +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; + +import { RadarController } from './radar.controller'; +import { RadarService } from './radar.service'; +import { RadarAssessment } from './radarAssessment.entity'; +import { RadarLookupService } from './radarLookup.service'; +import { RadarScoreService } from './radarScore.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([RadarAssessment]), + // O AuthModule precisa ser importado em cada módulo que usa o JwtAuthGuard. + AuthModule, + ], + providers: [RadarService, RadarScoreService, RadarLookupService], + controllers: [RadarController], + exports: [RadarScoreService], +}) +export class RadarModule {} diff --git a/src/modules/radar/radar.service.ts b/src/modules/radar/radar.service.ts new file mode 100644 index 0000000..3d08427 --- /dev/null +++ b/src/modules/radar/radar.service.ts @@ -0,0 +1,76 @@ +import { Injectable } from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; + +import { onlyDigits } from './radarLookup.service'; +import { RadarAssessment } from './radarAssessment.entity'; +import { RadarAnswers, RadarMode, RadarResult } from './radar.types'; +import { RadarScoreService } from './radarScore.service'; + +interface TenantInput { + programId: string; + storeId: string; +} + +interface SaveAssessmentInput extends TenantInput { + cnpj: string; + mode: RadarMode; + answers: RadarAnswers; +} + +/** + * Persistência da autoavaliação do Radar. + * + * O diagnóstico devolvido é sempre recalculado na leitura — regra nova de + * score vale imediatamente para avaliações antigas, sem migração de dado. + */ +@Injectable() +export class RadarService { + constructor( + @InjectRepository(RadarAssessment) + private readonly repository: Repository, + private readonly scoreService: RadarScoreService, + ) {} + + async getAssessment( + tenant: TenantInput, + cnpj: string, + ): Promise<(RadarAssessment & { result: RadarResult }) | null> { + const assessment = await this.repository.findOne({ + where: { + programId: tenant.programId, + storeId: tenant.storeId, + cnpj: onlyDigits(cnpj), + }, + }); + if (!assessment) return null; + return { + ...assessment, + result: this.scoreService.compute(assessment.answers), + }; + } + + async saveAssessment( + input: SaveAssessmentInput, + ): Promise { + const cnpj = onlyDigits(input.cnpj); + const existing = await this.repository.findOne({ + where: { + programId: input.programId, + storeId: input.storeId, + cnpj, + }, + }); + + const saved = await this.repository.save({ + ...(existing ?? {}), + programId: input.programId, + storeId: input.storeId, + cnpj, + mode: input.mode, + answers: input.answers, + }); + + return { ...saved, result: this.scoreService.compute(saved.answers) }; + } +} diff --git a/src/modules/radar/radar.types.ts b/src/modules/radar/radar.types.ts new file mode 100644 index 0000000..445fd9e --- /dev/null +++ b/src/modules/radar/radar.types.ts @@ -0,0 +1,140 @@ +/** + * Radar de Conformidade — tipos do domínio. + * + * Espelho do frontend (`services/Documents/radar/types.ts`), mesma decisão + * do score do painel de conformidade: o cálculo existe nos dois lados e + * qualquer mudança de regra tem que ser feita nos dois. + */ + +export enum RadarSphere { + Anp = 'anp', + Ambiental = 'ambiental', + Bombeiros = 'bombeiros', + Municipal = 'municipal', + Sst = 'sst', + Metrologia = 'metrologia', + Fiscal = 'fiscal', +} + +export type RadarTier = 'S' | 'A' | 'B' | 'C'; + +export enum RadarItemState { + Valid = 'valid', + Expired = 'expired', + Missing = 'missing', + InProcess = 'inProcess', + Unknown = 'unknown', + NotApplicable = 'notApplicable', +} + +export enum RadarEvidence { + Verified = 'verified', + Documented = 'documented', + Declared = 'declared', + Unverified = 'unverified', +} + +export interface RadarCatalogItem { + id: string; + sphere: RadarSphere; + weight: number; + tier: RadarTier; + hasValidity: boolean; + label: string; +} + +export interface RadarAnswer { + state: RadarItemState; + evidence: RadarEvidence; + /** Data de validade (YYYY-MM-DD). */ + expiryDate?: string; + /** Quando a resposta foi dada — declaração com >180 dias vale metade (R5). */ + answeredAt?: string; + source?: 'library' | 'anp' | 'user'; +} + +export type RadarAnswers = Record; + +export interface RadarBottleneck { + id: string; + label: string; + reasonKey: + | 'missing' + | 'longExpired' + | 'recentlyExpired' + | 'inProcess' + | 'unknown' + | 'expiringSoon' + | 'identityBlock'; + reasonDays?: number; + ceiling: number; +} + +export interface RadarScoredItem extends RadarCatalogItem { + compliance: number; + state: RadarItemState; + evidence: RadarEvidence; + daysToExpiry: number | null; + gain: number; + isBottleneck: boolean; +} + +export interface RadarResult { + score: number; + verifiedScore: number; + confidence: number; + band: 'A' | 'B' | 'C' | 'D' | 'E'; + ceiling: number | null; + bottlenecks: RadarBottleneck[]; + spheres: Partial>; + pendencies: RadarScoredItem[]; + expirations: RadarScoredItem[]; +} + +/** Identidade normalizada da Receita Federal (3 fontes, mesmo shape). */ +export interface CompanyIdentity { + cnpj: string; + legalName: string; + tradeName: string; + registrationStatus: string; + registrationStatusDate: string | null; + registrationStatusReason: string; + openedAt: string | null; + mainCnae: number | null; + mainCnaeDescription: string; + secondaryCnaes: { code: number; description: string }[]; + legalNature: string; + size: string; + headOrBranch: string; + specialSituation: string; + city: string; + uf: string; + address: string; + partnersCount: number; +} + +export interface CompanyLookup { + sourceName: string; + sourceUrl: string; + fetchedAt: string; + identity: CompanyIdentity; +} + +export interface AnpAuthorization { + authorizationNumber: string; + publicationDate: string | null; + flag: string; + products: { name: string; tankageM3: number | null }[]; + nozzles: number | null; + status: string; + raw: Record; +} + +export interface AnpLookup { + available: boolean; + fetchedAt: string; + sourceUrl: string; + authorization: AnpAuthorization | null; +} + +export type RadarMode = 'own' | 'thirdParty'; diff --git a/src/modules/radar/radarAssessment.entity.ts b/src/modules/radar/radarAssessment.entity.ts new file mode 100644 index 0000000..42cf1d4 --- /dev/null +++ b/src/modules/radar/radarAssessment.entity.ts @@ -0,0 +1,39 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Column, Entity, Index } from 'typeorm'; + +import { BaseCollection } from '../common/entities/base.entity'; +import { RadarAnswers, RadarMode } from './radar.types'; + +/** + * Autoavaliação do Radar de Conformidade — as respostas dos 17 itens de um + * CNPJ, por loja. + * + * Uma linha por (programa, loja, CNPJ): o gestor pode validar o próprio + * posto e também rascunhar diligência de terceiros. O diagnóstico nunca é + * persistido — é recalculado do jeito que as regras estiverem hoje. + */ +@Entity('radar_assessment') +@Index('uq_radar_assessment_tenant_cnpj', ['programId', 'storeId', 'cnpj'], { + unique: true, +}) +export class RadarAssessment extends BaseCollection { + @ApiProperty({ description: 'Programa (rede) dono da avaliação' }) + @Column({ type: 'uuid' }) + programId: string; + + @ApiProperty({ description: 'Loja em contexto' }) + @Column({ type: 'uuid' }) + storeId: string; + + @ApiProperty({ description: 'CNPJ avaliado, só dígitos' }) + @Column({ type: 'varchar', length: 14 }) + cnpj: string; + + @ApiProperty({ description: 'Modo: own (meu posto) ou thirdParty' }) + @Column({ type: 'varchar', length: 16, default: 'own' }) + mode: RadarMode; + + @ApiProperty({ description: 'Respostas por item do catálogo' }) + @Column({ type: 'jsonb', default: () => `'{}'::jsonb` }) + answers: RadarAnswers; +} diff --git a/src/modules/radar/radarCatalog.ts b/src/modules/radar/radarCatalog.ts new file mode 100644 index 0000000..d4c25b9 --- /dev/null +++ b/src/modules/radar/radarCatalog.ts @@ -0,0 +1,167 @@ +import { RadarCatalogItem, RadarSphere } from './radar.types'; + +/** + * Catálogo do Radar — 17 itens em 7 esferas, pesos somando 100. + * + * Pesos derivados de probabilidade × severidade (matriz da NR-01): o peso + * reflete severidade operacional, não facilidade de medir. A versão com + * textos completos (por quê, base legal, links) vive no frontend; o motor + * só precisa de peso, tier e validade. + */ + +export const RADAR_SPHERES: Record = { + [RadarSphere.Ambiental]: { name: 'Ambiental', weight: 25 }, + [RadarSphere.Anp]: { name: 'ANP — atividade-fim', weight: 20 }, + [RadarSphere.Bombeiros]: { name: 'Bombeiros', weight: 18 }, + [RadarSphere.Sst]: { name: 'Segurança e saúde', weight: 13 }, + [RadarSphere.Municipal]: { name: 'Municipal', weight: 12 }, + [RadarSphere.Metrologia]: { name: 'Metrologia e consumidor', weight: 7 }, + [RadarSphere.Fiscal]: { name: 'Fiscal e certidões', weight: 5 }, +}; + +export const RADAR_ITEMS: RadarCatalogItem[] = [ + { + id: 'anp_autorizacao', + sphere: RadarSphere.Anp, + weight: 12, + tier: 'S', + hasValidity: false, + label: 'Autorização ANP de revenda varejista', + }, + { + id: 'anp_interdicao', + sphere: RadarSphere.Anp, + weight: 5, + tier: 'S', + hasValidity: false, + label: 'Sem interdição ou suspensão vigente na ANP', + }, + { + id: 'anp_tancagem', + sphere: RadarSphere.Anp, + weight: 3, + tier: 'B', + hasValidity: false, + label: 'Tancagem e bicos conferem com o cadastro da ANP', + }, + { + id: 'amb_lo', + sphere: RadarSphere.Ambiental, + weight: 11, + tier: 'S', + hasValidity: true, + label: 'Licença de Operação ambiental vigente', + }, + { + id: 'amb_estanqueidade', + sphere: RadarSphere.Ambiental, + weight: 5, + tier: 'A', + hasValidity: true, + label: 'Teste de estanqueidade em dia', + }, + { + id: 'amb_pocos', + sphere: RadarSphere.Ambiental, + weight: 3, + tier: 'A', + hasValidity: true, + label: 'Monitoramento de água subterrânea e separador água-óleo', + }, + { + id: 'amb_pgrs', + sphere: RadarSphere.Ambiental, + weight: 3, + tier: 'B', + hasValidity: true, + label: 'PGRS e destinação de resíduos com manifesto', + }, + { + id: 'amb_emergencia', + sphere: RadarSphere.Ambiental, + weight: 3, + tier: 'B', + hasValidity: false, + label: 'Plano de emergência e contenção do SASC', + }, + { + id: 'bmb_avcb', + sphere: RadarSphere.Bombeiros, + weight: 13, + tier: 'S', + hasValidity: true, + label: 'AVCB vigente', + }, + { + id: 'bmb_brigada', + sphere: RadarSphere.Bombeiros, + weight: 5, + tier: 'A', + hasValidity: true, + label: 'Brigada e extintores na validade', + }, + { + id: 'mun_alvara', + sphere: RadarSphere.Municipal, + weight: 8, + tier: 'S', + hasValidity: true, + label: 'Alvará de funcionamento vigente', + }, + { + id: 'mun_sanitario', + sphere: RadarSphere.Municipal, + weight: 4, + tier: 'B', + hasValidity: true, + label: 'Alvará sanitário', + }, + { + id: 'sst_nr20', + sphere: RadarSphere.Sst, + weight: 5, + tier: 'A', + hasValidity: true, + label: 'Treinamentos NR-20 válidos', + }, + { + id: 'sst_pgr', + sphere: RadarSphere.Sst, + weight: 4, + tier: 'A', + hasValidity: true, + label: 'PGR (NR-01) atualizado', + }, + { + id: 'sst_pcmso', + sphere: RadarSphere.Sst, + weight: 4, + tier: 'B', + hasValidity: true, + label: 'PCMSO e ASOs em dia', + }, + { + id: 'met_afericao', + sphere: RadarSphere.Metrologia, + weight: 4, + tier: 'A', + hasValidity: true, + label: 'Verificação periódica das bombas', + }, + { + id: 'met_consumidor', + sphere: RadarSphere.Metrologia, + weight: 3, + tier: 'C', + hasValidity: false, + label: 'Painel de preços e informação ao consumidor', + }, + { + id: 'fis_certidoes', + sphere: RadarSphere.Fiscal, + weight: 5, + tier: 'C', + hasValidity: true, + label: 'Certidões e inscrição estadual ativa', + }, +]; diff --git a/src/modules/radar/radarLookup.service.ts b/src/modules/radar/radarLookup.service.ts new file mode 100644 index 0000000..298932b --- /dev/null +++ b/src/modules/radar/radarLookup.service.ts @@ -0,0 +1,297 @@ +import { MappedException } from '@clubpetrodev/nestjs-mapped-exception'; +import { Injectable, Logger } from '@nestjs/common'; + +import { RadarException } from './radar.exception'; +import { + AnpAuthorization, + AnpLookup, + CompanyIdentity, + CompanyLookup, +} from './radar.types'; + +/** + * Consultas externas do Radar — Camada 0 (Receita) e Camada 1 (ANP). + * + * Receita: três fontes públicas em cadeia de fallback (testadas em + * 11/08/2026). 404 na primeira fonte é resposta definitiva; erro de rede + * cai para a próxima. + * + * ANP: a API de revendedores não tem CORS e responde em ISO-8859-1 — este + * serviço é o repassador que o frontend usa no modo de API real. Falha na + * ANP degrada para `available: false`, nunca derruba o diagnóstico. + * + * LGPD: as fontes devolvem o QSA (dado pessoal dos sócios). Normalizamos + * para a contagem apenas, e nada de QSA é persistido. + */ + +const LOOKUP_TIMEOUT_MS = 12000; + +export const onlyDigits = (value: string): string => + (value || '').replace(/\D/g, ''); + +export const isValidCnpj = (value: string): boolean => { + const cnpj = onlyDigits(value); + if (cnpj.length !== 14 || /^(\d)\1{13}$/.test(cnpj)) return false; + const digit = (base: string, weights: number[]): number => { + const sum = base + .split('') + .reduce((acc, char, index) => acc + Number(char) * weights[index], 0); + const rest = sum % 11; + return rest < 2 ? 0 : 11 - rest; + }; + const d1 = digit(cnpj.slice(0, 12), [5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]); + const d2 = digit(cnpj.slice(0, 13), [6, 5, 4, 3, 2, 9, 8, 7, 6, 5, 4, 3, 2]); + return d1 === Number(cnpj[12]) && d2 === Number(cnpj[13]); +}; + +interface RfbPayload { + cnpj: string; + razao_social: string; + nome_fantasia?: string; + descricao_situacao_cadastral: string; + data_situacao_cadastral?: string; + descricao_motivo_situacao_cadastral?: string; + data_inicio_atividade?: string; + cnae_fiscal?: number; + cnae_fiscal_descricao?: string; + cnaes_secundarios?: { codigo: number; descricao: string }[]; + natureza_juridica?: string; + porte?: string; + descricao_identificador_matriz_filial?: string; + situacao_especial?: string; + municipio?: string; + uf?: string; + logradouro?: string; + numero?: string; + bairro?: string; + qsa?: unknown[]; +} + +interface CnpjaPayload { + taxId: string; + alias?: string; + founded?: string; + company?: { + name?: string; + nature?: { text?: string }; + size?: { text?: string }; + }; + status?: { text?: string }; + statusDate?: string; + reason?: { text?: string }; + head?: boolean; + mainActivity?: { id?: number; text?: string }; + sideActivities?: { id: number; text: string }[]; + address?: { + city?: string; + state?: string; + street?: string; + number?: string; + district?: string; + }; +} + +interface AnpRecord { + numeroAutorizacao?: string; + dataPublicacaoAutorizacao?: string; + bandeira?: string; + statusSIGAF?: string; + quantidadeBico?: number; + produtos?: { nomeProduto?: string; tancagem?: number }[]; + [key: string]: unknown; +} + +@Injectable() +export class RadarLookupService { + private readonly logger = new Logger(RadarLookupService.name); + + constructor(private readonly exception: MappedException) {} + + private async fetchWithTimeout( + url: string, + ms = LOOKUP_TIMEOUT_MS, + ): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), ms); + try { + return await fetch(url, { signal: controller.signal }); + } finally { + clearTimeout(timer); + } + } + + private normalizeRfb(payload: RfbPayload): CompanyIdentity { + return { + cnpj: onlyDigits(payload.cnpj), + legalName: payload.razao_social, + tradeName: payload.nome_fantasia || '', + registrationStatus: payload.descricao_situacao_cadastral || '', + registrationStatusDate: payload.data_situacao_cadastral || null, + registrationStatusReason: + payload.descricao_motivo_situacao_cadastral || '', + openedAt: payload.data_inicio_atividade || null, + mainCnae: payload.cnae_fiscal ?? null, + mainCnaeDescription: payload.cnae_fiscal_descricao || '', + secondaryCnaes: (payload.cnaes_secundarios || []).map((entry) => ({ + code: entry.codigo, + description: entry.descricao, + })), + legalNature: payload.natureza_juridica || '', + size: payload.porte || '', + headOrBranch: payload.descricao_identificador_matriz_filial || '', + specialSituation: payload.situacao_especial || '', + city: payload.municipio || '', + uf: payload.uf || '', + address: [payload.logradouro, payload.numero, payload.bairro] + .filter(Boolean) + .join(', '), + partnersCount: (payload.qsa || []).length, + }; + } + + private normalizeCnpja(payload: CnpjaPayload): CompanyIdentity { + return { + cnpj: onlyDigits(payload.taxId), + legalName: payload.company?.name || '', + tradeName: payload.alias || '', + registrationStatus: (payload.status?.text || '').toUpperCase(), + registrationStatusDate: payload.statusDate || null, + registrationStatusReason: payload.reason?.text || '', + openedAt: payload.founded || null, + mainCnae: payload.mainActivity?.id ?? null, + mainCnaeDescription: payload.mainActivity?.text || '', + secondaryCnaes: (payload.sideActivities || []).map((entry) => ({ + code: entry.id, + description: entry.text, + })), + legalNature: payload.company?.nature?.text || '', + size: payload.company?.size?.text || '', + headOrBranch: payload.head === false ? 'FILIAL' : 'MATRIZ', + specialSituation: '', + city: payload.address?.city || '', + uf: payload.address?.state || '', + address: [ + payload.address?.street, + payload.address?.number, + payload.address?.district, + ] + .filter(Boolean) + .join(', '), + partnersCount: 0, + }; + } + + async lookupCompany(cnpj: string): Promise { + const digits = onlyDigits(cnpj); + if (!isValidCnpj(digits)) this.exception.ERRORS.CNPJ_INVALID.throw(); + const fetchedAt = new Date().toISOString(); + + let notFound = false; + try { + const url = `https://brasilapi.com.br/api/cnpj/v1/${digits}`; + const response = await this.fetchWithTimeout(url); + if (response.ok) { + return { + sourceName: 'BrasilAPI (Receita Federal)', + sourceUrl: url, + fetchedAt, + identity: this.normalizeRfb(await response.json()), + }; + } + notFound = response.status === 404; + } catch (error) { + this.logger.warn(`BrasilAPI indisponível: ${error}`); + } + if (notFound) this.exception.ERRORS.CNPJ_NOT_FOUND.throw(); + + try { + const url = `https://minhareceita.org/${digits}`; + const response = await this.fetchWithTimeout(url); + if (response.ok) { + return { + sourceName: 'minhareceita.org (Receita Federal)', + sourceUrl: url, + fetchedAt, + identity: this.normalizeRfb(await response.json()), + }; + } + } catch (error) { + this.logger.warn(`minhareceita.org indisponível: ${error}`); + } + + try { + const url = `https://open.cnpja.com/office/${digits}`; + const response = await this.fetchWithTimeout(url); + if (response.ok) { + return { + sourceName: 'CNPJá Open (Receita Federal)', + sourceUrl: url, + fetchedAt, + identity: this.normalizeCnpja(await response.json()), + }; + } + } catch (error) { + this.logger.warn(`CNPJá Open indisponível: ${error}`); + } + + this.exception.ERRORS.SOURCES_UNAVAILABLE.throw(); + } + + private normalizeAnp(record: AnpRecord): AnpAuthorization { + return { + authorizationNumber: String(record.numeroAutorizacao ?? ''), + publicationDate: record.dataPublicacaoAutorizacao + ? String(record.dataPublicacaoAutorizacao) + : null, + flag: String(record.bandeira ?? ''), + products: (record.produtos || []).map((product) => ({ + name: String(product.nomeProduto ?? ''), + tankageM3: product.tancagem ?? null, + })), + nozzles: record.quantidadeBico ?? null, + status: String(record.statusSIGAF ?? ''), + raw: record as Record, + }; + } + + async lookupAnp(cnpj: string): Promise { + const digits = onlyDigits(cnpj); + if (!isValidCnpj(digits)) this.exception.ERRORS.CNPJ_INVALID.throw(); + const sourceUrl = `https://revendedoresapi.anp.gov.br/v1/combustivel?cnpj=${digits}`; + const fetchedAt = new Date().toISOString(); + + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), LOOKUP_TIMEOUT_MS); + let response: Response; + try { + response = await fetch(sourceUrl, { + signal: controller.signal, + headers: { 'User-Agent': 'Mozilla/5.0', Accept: 'application/json' }, + }); + } finally { + clearTimeout(timer); + } + if (!response.ok) throw new Error(`ANP respondeu ${response.status}`); + + // A ANP responde em ISO-8859-1: sem a conversão, a razão social e a + // bandeira chegam corrompidas. + const buffer = await response.arrayBuffer(); + const text = new TextDecoder('iso-8859-1').decode(buffer); + const payload = JSON.parse(text); + const records: AnpRecord[] = Array.isArray(payload) + ? payload + : (payload?.data ?? []); + + return { + available: true, + fetchedAt, + sourceUrl, + authorization: records.length ? this.normalizeAnp(records[0]) : null, + }; + } catch (error) { + this.logger.warn(`ANP indisponível: ${error}`); + return { available: false, fetchedAt, sourceUrl, authorization: null }; + } + } +} diff --git a/src/modules/radar/radarScore.service.ts b/src/modules/radar/radarScore.service.ts new file mode 100644 index 0000000..fd076b8 --- /dev/null +++ b/src/modules/radar/radarScore.service.ts @@ -0,0 +1,243 @@ +import { Injectable } from '@nestjs/common'; + +import { daysBetween, todayKey } from '../common/utils/status.util'; +import { RADAR_ITEMS } from './radarCatalog'; +import { + RadarAnswer, + RadarAnswers, + RadarBottleneck, + RadarEvidence, + RadarItemState, + RadarResult, + RadarScoredItem, + RadarSphere, +} from './radar.types'; + +/** + * Motor do Radar de Conformidade — espelho do frontend + * (`services/Documents/radar/engine.ts`). Regras R1–R7 da metodologia: + * + * R1 decaimento temporal · R2 gargalo por teto · R3 "não sei" = 0,25 · + * R4 evidência não altera o declarado · R5 declaração envelhece · + * R6 "não se aplica" sai do denominador · R7 clamp 0–100 sem decimais. + */ + +const clamp = (value: number, min: number, max: number): number => + Math.max(min, Math.min(max, value)); + +const clamp01 = (value: number): number => clamp(value, 0, 1); + +const BASE_STATE: Record = { + [RadarItemState.Valid]: 1.0, + [RadarItemState.Expired]: 1.0, + [RadarItemState.Missing]: 0.0, + [RadarItemState.InProcess]: 0.35, + [RadarItemState.Unknown]: 0.25, + [RadarItemState.NotApplicable]: 0.0, +}; + +const EVIDENCE_WEIGHT: Record = { + [RadarEvidence.Verified]: 1.0, + [RadarEvidence.Documented]: 0.8, + [RadarEvidence.Declared]: 0.35, + [RadarEvidence.Unverified]: 0.0, +}; + +const DECLARATION_STALE_DAYS = 180; + +const UNANSWERED: RadarAnswer = { + state: RadarItemState.Unknown, + evidence: RadarEvidence.Unverified, +}; + +@Injectable() +export class RadarScoreService { + /** + * R1 — 1,00 até 90 dias do vencimento, rampa a 0,60 aos 30, rampa a 0,30 + * no dia, decaimento exponencial com piso 0,05 depois de vencido. + */ + temporalFactor(daysToExpiry: number | null): number { + if (daysToExpiry === null || daysToExpiry === undefined) return 1.0; + if (daysToExpiry >= 90) return 1.0; + if (daysToExpiry >= 30) return 0.6 + 0.4 * ((daysToExpiry - 30) / 60); + if (daysToExpiry >= 0) return 0.3 + 0.3 * (daysToExpiry / 30); + return Math.max(0.05, 0.3 * Math.exp(0.0154 * daysToExpiry)); + } + + bandOf(score: number): RadarResult['band'] { + if (score >= 90) return 'A'; + if (score >= 75) return 'B'; + if (score >= 60) return 'C'; + if (score >= 40) return 'D'; + return 'E'; + } + + private daysToExpiry(answer: RadarAnswer, today: string): number | null { + return answer.expiryDate ? daysBetween(today, answer.expiryDate) : null; + } + + private effectiveEvidence(answer: RadarAnswer, today: string): number { + let weight = EVIDENCE_WEIGHT[answer.evidence] ?? 0; + if (answer.evidence === RadarEvidence.Declared && answer.answeredAt) { + const age = -daysBetween(today, answer.answeredAt); + if (age > DECLARATION_STALE_DAYS) weight *= 0.5; + } + return weight; + } + + /** Conformidade cᵢ ∈ [0,1]; null quando não se aplica (R6). */ + private itemCompliance(answer: RadarAnswer, today: string): number | null { + if (answer.state === RadarItemState.NotApplicable) return null; + const base = BASE_STATE[answer.state] ?? 0; + const days = this.daysToExpiry(answer, today); + // Vencido sem data: recém-vencido, nunca "em dia". + if (answer.state === RadarItemState.Expired && days === null) return 0.3; + if ( + answer.state === RadarItemState.Valid || + answer.state === RadarItemState.Expired + ) { + return clamp01(base * this.temporalFactor(days)); + } + return clamp01(base); + } + + private bottleneckOf( + item: RadarScoredItem, + ): Omit | null { + const days = item.daysToExpiry; + if (item.state === RadarItemState.Missing) + return { reasonKey: 'missing', ceiling: 39 }; + if (item.state === RadarItemState.Expired && days !== null && days < -30) + return { + reasonKey: 'longExpired', + reasonDays: Math.abs(days), + ceiling: 39, + }; + if (item.state === RadarItemState.Expired) + return { reasonKey: 'recentlyExpired', ceiling: 59 }; + if (item.state === RadarItemState.InProcess) + return { reasonKey: 'inProcess', ceiling: 59 }; + if (item.state === RadarItemState.Unknown) + return { reasonKey: 'unknown', ceiling: 79 }; + if (days !== null && days >= 0 && days <= 30) + return { reasonKey: 'expiringSoon', reasonDays: days, ceiling: 79 }; + return null; + } + + compute( + answers: RadarAnswers, + identityBlocked = false, + today: string = todayKey(), + ): RadarResult { + let totalWeight = 0; + let weightedSum = 0; + let confidenceSum = 0; + let verifiedSum = 0; + const bySphere: Partial< + Record + > = {}; + const items: RadarScoredItem[] = []; + + for (const catalogItem of RADAR_ITEMS) { + const answer = answers[catalogItem.id] || UNANSWERED; + const compliance = this.itemCompliance(answer, today); + if (compliance === null) continue; + + const weight = catalogItem.weight; + const evidence = this.effectiveEvidence(answer, today); + + totalWeight += weight; + weightedSum += weight * compliance; + confidenceSum += weight * evidence; + verifiedSum += weight * compliance * evidence; + + const sphere = (bySphere[catalogItem.sphere] ??= { weight: 0, sum: 0 }); + sphere.weight += weight; + sphere.sum += weight * compliance; + + items.push({ + ...catalogItem, + compliance, + state: answer.state, + evidence: answer.evidence, + daysToExpiry: this.daysToExpiry(answer, today), + gain: 0, + isBottleneck: false, + }); + } + + const weighted = totalWeight ? (weightedSum / totalWeight) * 100 : 0; + + let ceiling = 100; + const bottlenecks: RadarBottleneck[] = []; + for (const item of items) { + if (item.tier !== 'S') continue; + const found = this.bottleneckOf(item); + if (found) { + ceiling = Math.min(ceiling, found.ceiling); + item.isBottleneck = true; + bottlenecks.push({ id: item.id, label: item.label, ...found }); + } + } + if (identityBlocked) { + ceiling = Math.min(ceiling, 39); + bottlenecks.push({ + id: 'rfb', + label: 'Situação cadastral do CNPJ na Receita Federal', + reasonKey: 'identityBlock', + ceiling: 39, + }); + } + + const score = Math.round(clamp(Math.min(weighted, ceiling), 0, 100)); + const verifiedScore = Math.round( + clamp( + Math.min(totalWeight ? (verifiedSum / totalWeight) * 100 : 0, ceiling), + 0, + 100, + ), + ); + const confidence = Math.round( + totalWeight ? (confidenceSum / totalWeight) * 100 : 0, + ); + + for (const item of items) { + item.gain = totalWeight + ? +(((1 - item.compliance) * item.weight * 100) / totalWeight).toFixed( + 1, + ) + : 0; + } + + const bottleneckIds = new Set(bottlenecks.map((entry) => entry.id)); + + return { + score, + verifiedScore, + confidence, + band: this.bandOf(score), + ceiling: ceiling < 100 ? ceiling : null, + bottlenecks, + spheres: Object.fromEntries( + Object.entries(bySphere).map(([key, value]) => [ + key, + Math.round((value.sum / value.weight) * 100), + ]), + ) as Partial>, + pendencies: items + .filter((item) => item.compliance < 1) + .sort( + (a, b) => + Number(bottleneckIds.has(b.id)) - Number(bottleneckIds.has(a.id)) || + b.gain - a.gain || + (a.daysToExpiry ?? 9999) - (b.daysToExpiry ?? 9999), + ), + expirations: items + .filter( + (item) => + item.daysToExpiry !== null && item.state === RadarItemState.Valid, + ) + .sort((a, b) => (a.daysToExpiry ?? 0) - (b.daysToExpiry ?? 0)), + }; + } +}