feat(radar): validação de conformidade por CNPJ (Radar de Conformidade) — 1.2.0 #2
@ -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
|
||||
|
||||
13
CHANGELOG.md
13
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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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],
|
||||
|
||||
31
src/migrations/1786752000000-migration.ts
Normal file
31
src/migrations/1786752000000-migration.ts
Normal 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;`);
|
||||
}
|
||||
}
|
||||
151
src/modules/radar/__tests__/radarLookup.service.spec.ts
Normal file
151
src/modules/radar/__tests__/radarLookup.service.spec.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
192
src/modules/radar/__tests__/radarScore.service.spec.ts
Normal file
192
src/modules/radar/__tests__/radarScore.service.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
27
src/modules/radar/dto/request/radar.dto.ts
Normal file
27
src/modules/radar/dto/request/radar.dto.ts
Normal 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;
|
||||
}
|
||||
81
src/modules/radar/radar.controller.ts
Normal file
81
src/modules/radar/radar.controller.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
}
|
||||
22
src/modules/radar/radar.exception.ts
Normal file
22
src/modules/radar/radar.exception.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
21
src/modules/radar/radar.module.ts
Normal file
21
src/modules/radar/radar.module.ts
Normal 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 {}
|
||||
76
src/modules/radar/radar.service.ts
Normal file
76
src/modules/radar/radar.service.ts
Normal 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) };
|
||||
}
|
||||
}
|
||||
140
src/modules/radar/radar.types.ts
Normal file
140
src/modules/radar/radar.types.ts
Normal 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';
|
||||
39
src/modules/radar/radarAssessment.entity.ts
Normal file
39
src/modules/radar/radarAssessment.entity.ts
Normal 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;
|
||||
}
|
||||
167
src/modules/radar/radarCatalog.ts
Normal file
167
src/modules/radar/radarCatalog.ts
Normal 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
|
||||
* só 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',
|
||||
},
|
||||
];
|
||||
297
src/modules/radar/radarLookup.service.ts
Normal file
297
src/modules/radar/radarLookup.service.ts
Normal 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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
243
src/modules/radar/radarScore.service.ts
Normal file
243
src/modules/radar/radarScore.service.ts
Normal 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 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, 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)),
|
||||
};
|
||||
}
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user