Compare commits
No commits in common. "master" and "feat/ai-onda-1" have entirely different histories.
master
...
feat/ai-on
@ -16,21 +16,6 @@ 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)
|
### 2026-08-07 02:45 — Prazo de renovação dinâmico por órgão (Onda 1.2)
|
||||||
|
|
||||||
- **Tempo:** ~30 min
|
- **Tempo:** ~30 min
|
||||||
|
|||||||
13
CHANGELOG.md
13
CHANGELOG.md
@ -2,19 +2,6 @@
|
|||||||
|
|
||||||
Formato baseado em [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); versionamento semântico.
|
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
|
## [1.1.0] - 2026-08-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "documents",
|
"name": "documents",
|
||||||
"version": "1.2.0",
|
"version": "1.1.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)",
|
"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",
|
"author": "ClubPetro",
|
||||||
"private": true,
|
"private": true,
|
||||||
|
|||||||
@ -12,8 +12,6 @@ import { StorageModule } from './modules/common/storage/storage.module';
|
|||||||
import { ComplianceModule } from './modules/compliance/compliance.module';
|
import { ComplianceModule } from './modules/compliance/compliance.module';
|
||||||
import { StoreDocumentException } from './modules/document/document.exception';
|
import { StoreDocumentException } from './modules/document/document.exception';
|
||||||
import { DocumentModule } from './modules/document/document.module';
|
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';
|
import { config } from './ormconfig';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@ -21,7 +19,7 @@ import { config } from './ormconfig';
|
|||||||
ConfigModule.forRoot(),
|
ConfigModule.forRoot(),
|
||||||
TypeOrmModule.forRoot({ ...config, poolSize: 20 }),
|
TypeOrmModule.forRoot({ ...config, poolSize: 20 }),
|
||||||
MappedExceptionModule.forRoot(
|
MappedExceptionModule.forRoot(
|
||||||
[DocumentTypeException, StoreDocumentException, RadarException],
|
[DocumentTypeException, StoreDocumentException],
|
||||||
{ prefix: 'DOC' },
|
{ prefix: 'DOC' },
|
||||||
),
|
),
|
||||||
StorageModule,
|
StorageModule,
|
||||||
@ -29,7 +27,6 @@ import { config } from './ormconfig';
|
|||||||
DocumentModule,
|
DocumentModule,
|
||||||
ComplianceModule,
|
ComplianceModule,
|
||||||
AlertsModule,
|
AlertsModule,
|
||||||
RadarModule,
|
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [AppService],
|
providers: [AppService],
|
||||||
|
|||||||
@ -1,31 +0,0 @@
|
|||||||
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;`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,151 +0,0 @@
|
|||||||
/* 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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,192 +0,0 @@
|
|||||||
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);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@ -1,27 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,22 +0,0 @@
|
|||||||
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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@ -1,21 +0,0 @@
|
|||||||
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 {}
|
|
||||||
@ -1,76 +0,0 @@
|
|||||||
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) };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,140 +0,0 @@
|
|||||||
/**
|
|
||||||
* 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';
|
|
||||||
@ -1,39 +0,0 @@
|
|||||||
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;
|
|
||||||
}
|
|
||||||
@ -1,167 +0,0 @@
|
|||||||
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',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@ -1,297 +0,0 @@
|
|||||||
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 };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
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