Compare commits

...

3 Commits

Author SHA1 Message Date
20d07231cf Merge pull request 'feat(radar): validação de conformidade por CNPJ (Radar de Conformidade) — 1.2.0' (#2) from feat/compliance-radar into master
Some checks failed
CD / build (push) Failing after 8s
Reviewed-on: #2
2026-08-11 12:19:29 +00:00
Breno Pires
2006f2475f feat(radar): validacao de conformidade por CNPJ (Radar de Conformidade)
All checks were successful
CD / build (pull_request) Successful in 7s
Consulta Receita em cadeia de fallback e repasse da ANP (ISO-8859-1);
autoavaliacao de 17 itens por programa+loja+CNPJ em jsonb; motor de score
com regra de gargalo e tres numeros, espelho do frontend. 21 testes novos.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-11 02:55:57 -03:00
8cd9669717 Merge pull request 'feat: Onda 1 de IA — extração por LLM e prazo de renovação dinâmico (1.1.0)' (#1) from feat/ai-onda-1 into master
Some checks failed
CD / build (push) Failing after 9s
Reviewed-on: #1
2026-08-07 05:56:01 +00:00
17 changed files with 1520 additions and 2 deletions

View File

@ -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, R1R7, 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

View File

@ -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 AE, teto por gargalo (documento impeditivo), pendências
ordenadas por ganho e calendário de vencimentos. Motor espelho do frontend (regras R1R7 da
metodologia). Migration `radar_assessment` com up/down simétricos e idempotentes.
## [1.1.0] - 2026-08-07
### Added

View File

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

View File

@ -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],

View File

@ -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<void> {
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<void> {
await queryRunner.query(`DROP TABLE IF EXISTS public.radar_assessment;`);
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<RadarAssessment>,
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<RadarAssessment & { result: RadarResult }> {
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) };
}
}

View File

@ -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<string, RadarAnswer>;
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<Record<RadarSphere, number>>;
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<string, unknown>;
}
export interface AnpLookup {
available: boolean;
fetchedAt: string;
sourceUrl: string;
authorization: AnpAuthorization | null;
}
export type RadarMode = 'own' | 'thirdParty';

View File

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

View File

@ -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
* precisa de peso, tier e validade.
*/
export const RADAR_SPHERES: Record<RadarSphere, { name: string; weight: number }> = {
[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',
},
];

View File

@ -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<RadarException>) {}
private async fetchWithTimeout(
url: string,
ms = LOOKUP_TIMEOUT_MS,
): Promise<Response> {
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<CompanyLookup> {
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<string, unknown>,
};
}
async lookupAnp(cnpj: string): Promise<AnpLookup> {
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 };
}
}
}

View File

@ -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 R1R7 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 0100 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, number> = {
[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, number> = {
[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<RadarBottleneck, 'id' | 'label'> | 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<RadarSphere, { weight: number; sum: number }>
> = {};
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<Record<RadarSphere, number>>,
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)),
};
}
}