feat: Onda 1 de IA — extração por LLM e prazo de renovação dinâmico (1.1.0) #1
@ -39,3 +39,9 @@ TYPEORM_LOGGING=false
|
|||||||
|
|
||||||
URL_DEVELOPMENT=http://localhost:3000
|
URL_DEVELOPMENT=http://localhost:3000
|
||||||
URL_HOMOLOGATION=https://lab.clubpetro.com/api/v2/documents
|
URL_HOMOLOGATION=https://lab.clubpetro.com/api/v2/documents
|
||||||
|
|
||||||
|
# Análise por LLM (Onda 1.1). Sem a chave, a análise usa a heurística por
|
||||||
|
# nome de arquivo (comportamento v1).
|
||||||
|
# ANTHROPIC_API_KEY=
|
||||||
|
# Modelo multimodal usado na extração (default: claude-haiku-4-5-20251001)
|
||||||
|
# DOCUMENTS_LLM_MODEL=
|
||||||
|
|||||||
@ -16,6 +16,33 @@ Ordem cronológica — **mais recente no topo**.
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### 2026-08-07 02:45 — Prazo de renovação dinâmico por órgão (Onda 1.2)
|
||||||
|
|
||||||
|
- **Tempo:** ~30 min
|
||||||
|
- **Prompt:** substituir o lead fixo do catálogo pelo tempo real observado de emissão por órgão na
|
||||||
|
carteira (v0 do plano de IA — estatística descritiva, sem ML).
|
||||||
|
- **Mudanças:** entity + migration `renewal_cycle` (gravada automaticamente quando um save zera um
|
||||||
|
protocolo; descarta intervalos implausíveis); `LeadTimeService` (mediana/p90 por tipo e por
|
||||||
|
órgão; `effectiveLeadDays = max(catálogo, mediana+15)` com amostra ≥ 3 — catálogo é piso);
|
||||||
|
`GET /compliance/lead-times`; campos aditivos `effectiveLeadDays`/`observed` no upcoming e no
|
||||||
|
alerts/preview, com o degrau `lead` da régua usando o prazo dinâmico; seed determinístico
|
||||||
|
`seed-demo-cycles.ts`; 16 testes novos (54 no total, 7 suites). CHANGELOG 1.1.0.
|
||||||
|
- **Artefatos:** branch `feat/dynamic-lead-times` (worktree `documentos/documents-service-lead`)
|
||||||
|
|
||||||
|
### 2026-08-07 — Análise por LLM com confiança por campo (Onda 1.1)
|
||||||
|
|
||||||
|
- **Tempo:** ~30 min
|
||||||
|
- **Prompt:** executar a Onda 1.1 do PLANO-DOCUMENTOS-IA: extração real por LLM atrás da
|
||||||
|
interface `DocumentAnalyzer`, com confiança por campo, dupla passada em datas e fallback.
|
||||||
|
- **Mudanças:** `analyzer/anthropicAnalyzer.ts` (Messages API via fetch nativo, content block de
|
||||||
|
document/image em base64, duas passadas com fraseados diferentes, merge com bônus de
|
||||||
|
concordância e teto 0,98, parse tolerante, timeout 60s); contrato ganha `fieldConfidences` e
|
||||||
|
`evidence` (interface + DTO, retrocompatível); factory `documentAnalyzerFactory` no módulo
|
||||||
|
(sem `ANTHROPIC_API_KEY` → heurística v1 intacta); testes com fetch mockado (8 casos: passadas
|
||||||
|
convergentes/divergentes, erro de API, tipo inválido, arquivo grande, factory); `.env.example`
|
||||||
|
e README. Build limpo; 46 testes verdes (38 + 8).
|
||||||
|
- **Artefatos:** branch `feat/llm-analyzer`
|
||||||
|
|
||||||
### 2026-08-07 — Criação do serviço (espelho do routines)
|
### 2026-08-07 — Criação do serviço (espelho do routines)
|
||||||
|
|
||||||
- **Tempo:** ~60 min
|
- **Tempo:** ~60 min
|
||||||
|
|||||||
24
CHANGELOG.md
24
CHANGELOG.md
@ -2,6 +2,30 @@
|
|||||||
|
|
||||||
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.1.0] - 2026-08-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- **Análise por LLM** (`AnthropicAnalyzer`, Onda 1.1 do PLANO-DOCUMENTOS-IA): com
|
||||||
|
`ANTHROPIC_API_KEY`, o `POST /documents/analyze` extrai tipo, datas e número do PDF/imagem via
|
||||||
|
modelo multimodal (default Haiku, configurável por `DOCUMENTS_LLM_MODEL`), com **confiança por
|
||||||
|
campo** (`fieldConfidences`), **citação do trecho de origem** (`evidence`) e **dupla passada em
|
||||||
|
datas** — divergência entre as duas leituras zera o campo e devolve `dateMismatch` (data errada
|
||||||
|
é pior que campo vazio). Warnings novos no contrato: `dateMismatch`, `lowConfidence`,
|
||||||
|
`llmUnavailable`.
|
||||||
|
- Fallback gracioso: erro de API, arquivo >10MB ou mime não suportado caem na heurística por nome
|
||||||
|
de arquivo com `llmUnavailable` — a análise nunca falha por causa do LLM. Sem a chave, o
|
||||||
|
comportamento v1 fica intacto (factory em `document.module.ts`).
|
||||||
|
- **Prazo de renovação dinâmico por órgão (Onda 1.2 do plano de IA).** Cada renovação que sai do
|
||||||
|
órgão vira um ciclo observado (`renewal_cycle`: protocolo → emissão), gravado automaticamente no
|
||||||
|
save que zera um protocolo; ciclos implausíveis (negativos ou > 2 anos) são descartados com log.
|
||||||
|
- `GET /compliance/lead-times?programId`: mediana, p90 e prazo efetivo por órgão e por tipo.
|
||||||
|
Régua: `effectiveLeadDays = max(catálogo, mediana + 15)` com amostra ≥ 3 — o catálogo é piso
|
||||||
|
legal (os 120 dias da LO nunca encolhem), a estatística só estica quando o órgão está lento.
|
||||||
|
- `GET /compliance/upcoming` e `GET /alerts/preview` passam a carregar os campos aditivos
|
||||||
|
`effectiveLeadDays` e `observed` `{count, medianDays}`; o degrau `lead` da régua de alertas
|
||||||
|
dispara pelo prazo dinâmico ("a SEMAD está levando 140 dias — comece agora").
|
||||||
|
- Seed de demonstração `src/scripts/seed-demo-cycles.ts` (determinístico) para o LAB.
|
||||||
|
|
||||||
## [1.0.0] - 2026-08-07
|
## [1.0.0] - 2026-08-07
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
10
README.md
10
README.md
@ -27,6 +27,16 @@ Regras de negócio centrais em `src/modules/common/utils/status.util.ts` (status
|
|||||||
como fonte da verdade. O detalhe jurídico que o modelo carrega: **renovação protocolada no prazo
|
como fonte da verdade. O detalhe jurídico que o modelo carrega: **renovação protocolada no prazo
|
||||||
mantém o documento operante** (na LO da CETESB, protocolo ≥120 dias antes prorroga a validade).
|
mantém o documento operante** (na LO da CETESB, protocolo ≥120 dias antes prorroga a validade).
|
||||||
|
|
||||||
|
## Análise por LLM
|
||||||
|
|
||||||
|
Com `ANTHROPIC_API_KEY` no ambiente, o `POST /documents/analyze` extrai os campos do PDF/imagem
|
||||||
|
com um modelo multimodal (`DOCUMENTS_LLM_MODEL`, default Haiku): confiança por campo
|
||||||
|
(`fieldConfidences`), citação do trecho de origem (`evidence`) e **dupla passada em datas** —
|
||||||
|
divergência entre as duas leituras zera o campo e devolve o warning `dateMismatch`, porque data
|
||||||
|
errada é pior que campo vazio. Qualquer falha do LLM (API fora, arquivo >10MB, mime estranho) cai
|
||||||
|
na heurística por nome de arquivo com o warning `llmUnavailable` — a análise nunca quebra o upload.
|
||||||
|
Sem a chave, o serviço se comporta como o v1 (heurística pura).
|
||||||
|
|
||||||
## Rodar local
|
## Rodar local
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|||||||
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "documents",
|
"name": "documents",
|
||||||
"version": "1.0.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,
|
||||||
|
|||||||
34
src/migrations/1786233600000-migration.ts
Normal file
34
src/migrations/1786233600000-migration.ts
Normal file
@ -0,0 +1,34 @@
|
|||||||
|
import { MigrationInterface, QueryRunner } from 'typeorm';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Ciclos de renovação observados (protocolo → emissão), base do prazo de
|
||||||
|
* renovação dinâmico por órgão. Reusa o `issuing_body_enum` da migration
|
||||||
|
* inicial; índice por (programId, issuingBody) porque toda leitura agrega
|
||||||
|
* dentro do programa.
|
||||||
|
*/
|
||||||
|
export class migration1786233600000 implements MigrationInterface {
|
||||||
|
name = 'migration1786233600000';
|
||||||
|
|
||||||
|
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`
|
||||||
|
CREATE TABLE IF NOT EXISTS public.renewal_cycle (
|
||||||
|
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,
|
||||||
|
"typeCode" varchar(80) NOT NULL,
|
||||||
|
"issuingBody" public.issuing_body_enum NOT NULL,
|
||||||
|
"protocolDate" date NOT NULL,
|
||||||
|
"issuedDate" date NOT NULL,
|
||||||
|
"emissionDays" int NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_renewal_cycle_program_body
|
||||||
|
ON public.renewal_cycle ("programId","issuingBody");
|
||||||
|
`);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async down(queryRunner: QueryRunner): Promise<void> {
|
||||||
|
await queryRunner.query(`DROP TABLE IF EXISTS public.renewal_cycle;`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -71,4 +71,33 @@ describe('AlertsService.computeDueAlerts — a régua', () => {
|
|||||||
await service.computeDueAlerts({ programId: PROGRAM_ID, storeId: STORE_ID }),
|
await service.computeDueAlerts({ programId: PROGRAM_ID, storeId: STORE_ID }),
|
||||||
).toHaveLength(0);
|
).toHaveLength(0);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('prazo dinâmico: effectiveLeadDays maior que o catálogo dispara o degrau lead mais cedo', async () => {
|
||||||
|
// 130 dias para vencer: fora do lead do catálogo (120), dentro do
|
||||||
|
// observado (mediana 140 + 15 = 155)
|
||||||
|
const service = setup([
|
||||||
|
upcoming('lo', 130, {
|
||||||
|
renewalLeadDays: 120,
|
||||||
|
effectiveLeadDays: 155,
|
||||||
|
observed: { count: 6, medianDays: 140 },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const alerts = await service.computeDueAlerts({
|
||||||
|
programId: PROGRAM_ID,
|
||||||
|
storeId: STORE_ID,
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(alerts).toHaveLength(1);
|
||||||
|
expect(alerts[0].trigger).toBe('lead');
|
||||||
|
expect(alerts[0].effectiveLeadDays).toBe(155);
|
||||||
|
expect(alerts[0].observed).toEqual({ count: 6, medianDays: 140 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sem effectiveLeadDays no item, a régua usa o prazo do catálogo (retrocompatível)', async () => {
|
||||||
|
const service = setup([upcoming('lo', 130, { renewalLeadDays: 120 })]);
|
||||||
|
expect(
|
||||||
|
await service.computeDueAlerts({ programId: PROGRAM_ID, storeId: STORE_ID }),
|
||||||
|
).toHaveLength(0);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@ -11,6 +11,10 @@ export interface IDueAlert {
|
|||||||
trigger: 'lead' | 'd30' | 'd15' | 'd7' | 'd1' | 'expired';
|
trigger: 'lead' | 'd30' | 'd15' | 'd7' | 'd1' | 'expired';
|
||||||
daysToExpiry: number;
|
daysToExpiry: number;
|
||||||
expiryDate: string;
|
expiryDate: string;
|
||||||
|
/** O prazo que disparou o degrau `lead` — dinâmico quando há amostra. */
|
||||||
|
effectiveLeadDays: number;
|
||||||
|
/** Amostra observada do órgão que esticou o prazo; nulo = só catálogo. */
|
||||||
|
observed?: { count: number; medianDays: number } | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -47,7 +51,10 @@ export class AlertsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toAlert(item: UpcomingExpirationDto): IDueAlert | null {
|
private toAlert(item: UpcomingExpirationDto): IDueAlert | null {
|
||||||
const trigger = this.resolveTrigger(item.daysToExpiry, item.renewalLeadDays);
|
// Prazo dinâmico (Onda 1.2): quando o órgão está comprovadamente mais
|
||||||
|
// lento que o prazo do catálogo, o degrau `lead` dispara mais cedo.
|
||||||
|
const lead = item.effectiveLeadDays ?? item.renewalLeadDays;
|
||||||
|
const trigger = this.resolveTrigger(item.daysToExpiry, lead);
|
||||||
if (!trigger) return null;
|
if (!trigger) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@ -56,6 +63,8 @@ export class AlertsService {
|
|||||||
trigger,
|
trigger,
|
||||||
daysToExpiry: item.daysToExpiry,
|
daysToExpiry: item.daysToExpiry,
|
||||||
expiryDate: item.expiryDate,
|
expiryDate: item.expiryDate,
|
||||||
|
effectiveLeadDays: lead,
|
||||||
|
observed: item.observed ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,7 +5,9 @@ import {
|
|||||||
StoreDocumentStatus,
|
StoreDocumentStatus,
|
||||||
} from '../../common/enum/document.enum';
|
} from '../../common/enum/document.enum';
|
||||||
import { todayKey } from '../../common/utils/status.util';
|
import { todayKey } from '../../common/utils/status.util';
|
||||||
|
import { IssuingBody } from '../../common/enum/document.enum';
|
||||||
import { ComplianceService } from '../compliance.service';
|
import { ComplianceService } from '../compliance.service';
|
||||||
|
import { LeadTimeService } from '../leadTime.service';
|
||||||
|
|
||||||
const PROGRAM_ID = '47031ca8-7586-40ba-acf6-f7af26d56a13';
|
const PROGRAM_ID = '47031ca8-7586-40ba-acf6-f7af26d56a13';
|
||||||
const STORE_ID = '6b4f2dfa-2400-4b6e-a587-18978677ef20';
|
const STORE_ID = '6b4f2dfa-2400-4b6e-a587-18978677ef20';
|
||||||
@ -20,12 +22,14 @@ const type = (code: string, criticality: DocumentCriticality, lead = 30) => ({
|
|||||||
code,
|
code,
|
||||||
name: code,
|
name: code,
|
||||||
criticality,
|
criticality,
|
||||||
|
issuingBody: IssuingBody.OrgaoAmbiental,
|
||||||
renewalLeadDays: lead,
|
renewalLeadDays: lead,
|
||||||
});
|
});
|
||||||
|
|
||||||
const setup = ({
|
const setup = ({
|
||||||
types = [] as any[],
|
types = [] as any[],
|
||||||
documents = [] as any[],
|
documents = [] as any[],
|
||||||
|
cycles = [] as any[],
|
||||||
} = {}) => {
|
} = {}) => {
|
||||||
const documentRepository = repositoryMock({
|
const documentRepository = repositoryMock({
|
||||||
find: jest.fn().mockResolvedValue(documents),
|
find: jest.fn().mockResolvedValue(documents),
|
||||||
@ -33,9 +37,19 @@ const setup = ({
|
|||||||
const catalogService = {
|
const catalogService = {
|
||||||
getActiveTypes: jest.fn().mockResolvedValue(types),
|
getActiveTypes: jest.fn().mockResolvedValue(types),
|
||||||
};
|
};
|
||||||
|
// LeadTimeService real com repositório mockado: os specs do upcoming
|
||||||
|
// exercitam a integração de verdade, não um dublê da régua.
|
||||||
|
const cycleRepository = repositoryMock({
|
||||||
|
find: jest.fn().mockResolvedValue(cycles),
|
||||||
|
});
|
||||||
|
const leadTimeService = new LeadTimeService(
|
||||||
|
cycleRepository as any,
|
||||||
|
catalogService as any,
|
||||||
|
);
|
||||||
const service = new ComplianceService(
|
const service = new ComplianceService(
|
||||||
documentRepository as any,
|
documentRepository as any,
|
||||||
catalogService as any,
|
catalogService as any,
|
||||||
|
leadTimeService,
|
||||||
);
|
);
|
||||||
return { service };
|
return { service };
|
||||||
};
|
};
|
||||||
@ -175,4 +189,43 @@ describe('ComplianceService.getUpcoming', () => {
|
|||||||
expect(upcoming[0].daysToExpiry).toBe(-3);
|
expect(upcoming[0].daysToExpiry).toBe(-3);
|
||||||
expect(upcoming[0].status).toBe(StoreDocumentStatus.Expired);
|
expect(upcoming[0].status).toBe(StoreDocumentStatus.Expired);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('sem histórico, o prazo efetivo é o do catálogo e observed é nulo', async () => {
|
||||||
|
const { service } = setup({
|
||||||
|
types: [type('a', DocumentCriticality.Multa, 30)],
|
||||||
|
documents: [
|
||||||
|
{ typeCode: 'a', expiryDate: futureDate(10), renewalProtocolNumber: null },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
const [item] = await service.getUpcoming({
|
||||||
|
programId: PROGRAM_ID,
|
||||||
|
storeId: STORE_ID,
|
||||||
|
days: 90,
|
||||||
|
});
|
||||||
|
expect(item.effectiveLeadDays).toBe(30);
|
||||||
|
expect(item.observed).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('com histórico do órgão mais lento que o catálogo, o prazo efetivo estica', async () => {
|
||||||
|
const cycle = (emissionDays: number) => ({
|
||||||
|
typeCode: 'a',
|
||||||
|
issuingBody: IssuingBody.OrgaoAmbiental,
|
||||||
|
emissionDays,
|
||||||
|
});
|
||||||
|
const { service } = setup({
|
||||||
|
types: [type('a', DocumentCriticality.Multa, 30)],
|
||||||
|
documents: [
|
||||||
|
{ typeCode: 'a', expiryDate: futureDate(10), renewalProtocolNumber: null },
|
||||||
|
],
|
||||||
|
cycles: [cycle(60), cycle(70), cycle(80)],
|
||||||
|
});
|
||||||
|
const [item] = await service.getUpcoming({
|
||||||
|
programId: PROGRAM_ID,
|
||||||
|
storeId: STORE_ID,
|
||||||
|
days: 90,
|
||||||
|
});
|
||||||
|
expect(item.effectiveLeadDays).toBe(85); // mediana 70 + margem 15
|
||||||
|
expect(item.observed).toEqual({ count: 3, medianDays: 70 });
|
||||||
|
expect(item.renewalLeadDays).toBe(30); // o do catálogo continua exposto
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
153
src/modules/compliance/__tests__/leadTime.service.spec.ts
Normal file
153
src/modules/compliance/__tests__/leadTime.service.spec.ts
Normal file
@ -0,0 +1,153 @@
|
|||||||
|
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||||
|
import { IssuingBody } from '../../common/enum/document.enum';
|
||||||
|
import { repositoryMock } from '../../common/__tests__/mock/repository.mock';
|
||||||
|
import { LeadTimeService, MIN_SAMPLE, SAFETY_MARGIN_DAYS } from '../leadTime.service';
|
||||||
|
|
||||||
|
const PROGRAM_ID = '47031ca8-7586-40ba-acf6-f7af26d56a13';
|
||||||
|
|
||||||
|
const cycle = (
|
||||||
|
emissionDays: number,
|
||||||
|
overrides: Record<string, any> = {},
|
||||||
|
): Record<string, any> => ({
|
||||||
|
programId: PROGRAM_ID,
|
||||||
|
storeId: 'store-1',
|
||||||
|
typeCode: 'licenca-operacao',
|
||||||
|
issuingBody: IssuingBody.OrgaoAmbiental,
|
||||||
|
emissionDays,
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
const loType = {
|
||||||
|
code: 'licenca-operacao',
|
||||||
|
issuingBody: IssuingBody.OrgaoAmbiental,
|
||||||
|
renewalLeadDays: 120,
|
||||||
|
};
|
||||||
|
|
||||||
|
const setup = (cycles: any[], types: any[] = [loType]) => {
|
||||||
|
const cycleRepository = repositoryMock({
|
||||||
|
find: jest.fn().mockResolvedValue(cycles),
|
||||||
|
});
|
||||||
|
const catalogService = {
|
||||||
|
getActiveTypes: jest.fn().mockResolvedValue(types),
|
||||||
|
};
|
||||||
|
return new LeadTimeService(cycleRepository as any, catalogService as any);
|
||||||
|
};
|
||||||
|
|
||||||
|
describe('LeadTimeService — mediana e p90', () => {
|
||||||
|
it('mediana com amostra ímpar é o valor do meio', async () => {
|
||||||
|
const service = setup([cycle(100), cycle(140), cycle(180)]);
|
||||||
|
const index = await service.getIndex(PROGRAM_ID);
|
||||||
|
expect(index.byType.get('licenca-operacao')).toEqual({
|
||||||
|
count: 3,
|
||||||
|
medianDays: 140,
|
||||||
|
p90Days: 180,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('mediana com amostra par é a média (arredondada) dos dois do meio', async () => {
|
||||||
|
const service = setup([cycle(100), cycle(120), cycle(141), cycle(200)]);
|
||||||
|
const index = await service.getIndex(PROGRAM_ID);
|
||||||
|
const stats = index.byType.get('licenca-operacao');
|
||||||
|
expect(stats?.medianDays).toBe(131); // (120 + 141) / 2 arredondado
|
||||||
|
expect(stats?.p90Days).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LeadTimeService.effectiveLeadFor — a régua', () => {
|
||||||
|
it('sem amostra suficiente (count < MIN_SAMPLE) vale o catálogo, sem observed', async () => {
|
||||||
|
const service = setup([cycle(300), cycle(310)]); // só 2
|
||||||
|
const index = await service.getIndex(PROGRAM_ID);
|
||||||
|
|
||||||
|
expect(service.effectiveLeadFor(loType, index)).toEqual({
|
||||||
|
effectiveLeadDays: 120,
|
||||||
|
observed: null,
|
||||||
|
});
|
||||||
|
expect(MIN_SAMPLE).toBeGreaterThan(2);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('órgão mais lento que o catálogo estica o prazo: mediana + margem', async () => {
|
||||||
|
const service = setup([cycle(130), cycle(140), cycle(150)]);
|
||||||
|
const index = await service.getIndex(PROGRAM_ID);
|
||||||
|
|
||||||
|
expect(service.effectiveLeadFor(loType, index)).toEqual({
|
||||||
|
effectiveLeadDays: 140 + SAFETY_MARGIN_DAYS,
|
||||||
|
observed: { count: 3, medianDays: 140 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('órgão rápido NUNCA encolhe o prazo abaixo do catálogo (piso legal)', async () => {
|
||||||
|
const service = setup([cycle(20), cycle(25), cycle(30)]);
|
||||||
|
const index = await service.getIndex(PROGRAM_ID);
|
||||||
|
|
||||||
|
const lead = service.effectiveLeadFor(loType, index);
|
||||||
|
expect(lead.effectiveLeadDays).toBe(120); // catálogo é piso
|
||||||
|
expect(lead.observed).toEqual({ count: 3, medianDays: 25 });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sem amostra do tipo, cai na amostra do órgão inteiro', async () => {
|
||||||
|
const service = setup([
|
||||||
|
cycle(200, { typeCode: 'destinacao-residuos' }),
|
||||||
|
cycle(210, { typeCode: 'destinacao-residuos' }),
|
||||||
|
cycle(220, { typeCode: 'destinacao-residuos' }),
|
||||||
|
]);
|
||||||
|
const index = await service.getIndex(PROGRAM_ID);
|
||||||
|
|
||||||
|
// licenca-operacao não tem ciclo próprio, mas o órgão ambiental tem 3
|
||||||
|
expect(service.effectiveLeadFor(loType, index)).toEqual({
|
||||||
|
effectiveLeadDays: 210 + SAFETY_MARGIN_DAYS,
|
||||||
|
observed: { count: 3, medianDays: 210 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('amostra do tipo vence a do órgão quando as duas existem', async () => {
|
||||||
|
const service = setup([
|
||||||
|
cycle(140),
|
||||||
|
cycle(150),
|
||||||
|
cycle(160),
|
||||||
|
cycle(40, { typeCode: 'destinacao-residuos' }),
|
||||||
|
cycle(45, { typeCode: 'destinacao-residuos' }),
|
||||||
|
cycle(50, { typeCode: 'destinacao-residuos' }),
|
||||||
|
]);
|
||||||
|
const index = await service.getIndex(PROGRAM_ID);
|
||||||
|
|
||||||
|
expect(service.effectiveLeadFor(loType, index).observed?.medianDays).toBe(
|
||||||
|
150,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('LeadTimeService.getLeadTimes — tabela do painel', () => {
|
||||||
|
it('linha por órgão (typeCode nulo) + linha por tipo, com o catálogo como piso', async () => {
|
||||||
|
const service = setup([cycle(140), cycle(150), cycle(160)]);
|
||||||
|
|
||||||
|
const rows = await service.getLeadTimes(PROGRAM_ID);
|
||||||
|
|
||||||
|
const bodyRow = rows.find(row => row.typeCode === null);
|
||||||
|
const typeRow = rows.find(row => row.typeCode === 'licenca-operacao');
|
||||||
|
|
||||||
|
expect(bodyRow).toMatchObject({
|
||||||
|
issuingBody: IssuingBody.OrgaoAmbiental,
|
||||||
|
count: 3,
|
||||||
|
medianDays: 150,
|
||||||
|
catalogLeadDays: 120,
|
||||||
|
effectiveLeadDays: 150 + SAFETY_MARGIN_DAYS,
|
||||||
|
});
|
||||||
|
expect(typeRow).toMatchObject({
|
||||||
|
count: 3,
|
||||||
|
effectiveLeadDays: 150 + SAFETY_MARGIN_DAYS,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('amostra pequena aparece na tabela mas o prazo efetivo fica no catálogo', async () => {
|
||||||
|
const service = setup([cycle(300)]);
|
||||||
|
|
||||||
|
const rows = await service.getLeadTimes(PROGRAM_ID);
|
||||||
|
const typeRow = rows.find(row => row.typeCode === 'licenca-operacao');
|
||||||
|
|
||||||
|
expect(typeRow).toMatchObject({
|
||||||
|
count: 1,
|
||||||
|
medianDays: 300,
|
||||||
|
effectiveLeadDays: 120,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -12,13 +12,15 @@ import {
|
|||||||
import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger';
|
import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger';
|
||||||
|
|
||||||
import { FiltersInterceptor } from '../../interceptors/filters.interceptor';
|
import { FiltersInterceptor } from '../../interceptors/filters.interceptor';
|
||||||
import { TenantWithStoreDto } from '../common/dto/tenant.dto';
|
import { TenantDto, TenantWithStoreDto } from '../common/dto/tenant.dto';
|
||||||
import { ComplianceService } from './compliance.service';
|
import { ComplianceService } from './compliance.service';
|
||||||
import {
|
import {
|
||||||
ComplianceSummaryResponseDto,
|
ComplianceSummaryResponseDto,
|
||||||
UpcomingExpirationDto,
|
UpcomingExpirationDto,
|
||||||
} from './dto/response/compliance.dto';
|
} from './dto/response/compliance.dto';
|
||||||
|
import { LeadTimesResponseDto } from './dto/response/leadTime.dto';
|
||||||
import { GetUpcomingDto } from './dto/request/getUpcoming.dto';
|
import { GetUpcomingDto } from './dto/request/getUpcoming.dto';
|
||||||
|
import { LeadTimeService } from './leadTime.service';
|
||||||
|
|
||||||
@ApiTags('Conformidade')
|
@ApiTags('Conformidade')
|
||||||
@Controller('compliance')
|
@Controller('compliance')
|
||||||
@ -26,7 +28,10 @@ import { GetUpcomingDto } from './dto/request/getUpcoming.dto';
|
|||||||
@UseInterceptors(FiltersInterceptor)
|
@UseInterceptors(FiltersInterceptor)
|
||||||
@UseGuards(JwtAuthGuard)
|
@UseGuards(JwtAuthGuard)
|
||||||
export class ComplianceController {
|
export class ComplianceController {
|
||||||
constructor(private readonly service: ComplianceService) {}
|
constructor(
|
||||||
|
private readonly service: ComplianceService,
|
||||||
|
private readonly leadTimeService: LeadTimeService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('summary')
|
@Get('summary')
|
||||||
@ApiBearerAuth()
|
@ApiBearerAuth()
|
||||||
@ -45,4 +50,13 @@ export class ComplianceController {
|
|||||||
days: filters.days ?? 90,
|
days: filters.days ?? 90,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Get('lead-times')
|
||||||
|
@ApiBearerAuth()
|
||||||
|
@ApiResponse({ status: HttpStatus.OK, type: LeadTimesResponseDto })
|
||||||
|
async getLeadTimes(
|
||||||
|
@Query() filters: TenantDto,
|
||||||
|
): Promise<LeadTimesResponseDto> {
|
||||||
|
return { data: await this.leadTimeService.getLeadTimes(filters.programId) };
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,16 +6,18 @@ import { CatalogModule } from '../catalog/catalog.module';
|
|||||||
import { StoreDocument } from '../document/storeDocument.entity';
|
import { StoreDocument } from '../document/storeDocument.entity';
|
||||||
import { ComplianceController } from './compliance.controller';
|
import { ComplianceController } from './compliance.controller';
|
||||||
import { ComplianceService } from './compliance.service';
|
import { ComplianceService } from './compliance.service';
|
||||||
|
import { LeadTimeService } from './leadTime.service';
|
||||||
|
import { RenewalCycle } from './renewalCycle.entity';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([StoreDocument]),
|
TypeOrmModule.forFeature([StoreDocument, RenewalCycle]),
|
||||||
CatalogModule,
|
CatalogModule,
|
||||||
// O AuthModule precisa ser importado em cada módulo que usa o JwtAuthGuard.
|
// O AuthModule precisa ser importado em cada módulo que usa o JwtAuthGuard.
|
||||||
AuthModule,
|
AuthModule,
|
||||||
],
|
],
|
||||||
providers: [ComplianceService],
|
providers: [ComplianceService, LeadTimeService],
|
||||||
controllers: [ComplianceController],
|
controllers: [ComplianceController],
|
||||||
exports: [ComplianceService],
|
exports: [ComplianceService, LeadTimeService],
|
||||||
})
|
})
|
||||||
export class ComplianceModule {}
|
export class ComplianceModule {}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
ComplianceSummaryResponseDto,
|
ComplianceSummaryResponseDto,
|
||||||
UpcomingExpirationDto,
|
UpcomingExpirationDto,
|
||||||
} from './dto/response/compliance.dto';
|
} from './dto/response/compliance.dto';
|
||||||
|
import { LeadTimeService } from './leadTime.service';
|
||||||
|
|
||||||
/** Peso da criticidade no score — o mesmo do painel. */
|
/** Peso da criticidade no score — o mesmo do painel. */
|
||||||
const CRITICALITY_WEIGHT: Record<DocumentCriticality, number> = {
|
const CRITICALITY_WEIGHT: Record<DocumentCriticality, number> = {
|
||||||
@ -38,6 +39,7 @@ export class ComplianceService {
|
|||||||
@InjectRepository(StoreDocument)
|
@InjectRepository(StoreDocument)
|
||||||
private readonly documentRepository: Repository<StoreDocument>,
|
private readonly documentRepository: Repository<StoreDocument>,
|
||||||
private readonly catalogService: CatalogService,
|
private readonly catalogService: CatalogService,
|
||||||
|
private readonly leadTimeService: LeadTimeService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -109,26 +111,40 @@ export class ComplianceService {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Vencimentos na janela, ordenados — a matéria-prima dos alertas. */
|
/**
|
||||||
|
* Vencimentos na janela, ordenados — a matéria-prima dos alertas.
|
||||||
|
*
|
||||||
|
* Cada item carrega o prazo dinâmico (`effectiveLeadDays`): o catálogo é
|
||||||
|
* piso, e a mediana observada do órgão na carteira o estica quando o órgão
|
||||||
|
* está mais lento que o prazo legal supõe.
|
||||||
|
*/
|
||||||
async getUpcoming(params: {
|
async getUpcoming(params: {
|
||||||
programId: string;
|
programId: string;
|
||||||
storeId: string;
|
storeId: string;
|
||||||
days: number;
|
days: number;
|
||||||
}): Promise<UpcomingExpirationDto[]> {
|
}): Promise<UpcomingExpirationDto[]> {
|
||||||
const obligations = await this.getObligations(params);
|
const [obligations, leadIndex] = await Promise.all([
|
||||||
|
this.getObligations(params),
|
||||||
|
this.leadTimeService.getIndex(params.programId),
|
||||||
|
]);
|
||||||
const today = todayKey();
|
const today = todayKey();
|
||||||
|
|
||||||
return obligations
|
return obligations
|
||||||
.filter(({ document }) => !!document?.expiryDate)
|
.filter(({ document }) => !!document?.expiryDate)
|
||||||
.map(({ type, status, document }) => ({
|
.map(({ type, status, document }) => {
|
||||||
typeCode: type.code,
|
const lead = this.leadTimeService.effectiveLeadFor(type, leadIndex);
|
||||||
typeName: type.name,
|
return {
|
||||||
criticality: type.criticality,
|
typeCode: type.code,
|
||||||
status,
|
typeName: type.name,
|
||||||
expiryDate: document!.expiryDate!,
|
criticality: type.criticality,
|
||||||
daysToExpiry: daysBetween(today, document!.expiryDate!),
|
status,
|
||||||
renewalLeadDays: type.renewalLeadDays,
|
expiryDate: document!.expiryDate!,
|
||||||
}))
|
daysToExpiry: daysBetween(today, document!.expiryDate!),
|
||||||
|
renewalLeadDays: type.renewalLeadDays,
|
||||||
|
effectiveLeadDays: lead.effectiveLeadDays,
|
||||||
|
observed: lead.observed,
|
||||||
|
};
|
||||||
|
})
|
||||||
.filter(item => item.daysToExpiry <= params.days)
|
.filter(item => item.daysToExpiry <= params.days)
|
||||||
.sort((a, b) => a.daysToExpiry - b.daysToExpiry);
|
.sort((a, b) => a.daysToExpiry - b.daysToExpiry);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,9 +1,10 @@
|
|||||||
import { ApiProperty } from '@nestjs/swagger';
|
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DocumentCriticality,
|
DocumentCriticality,
|
||||||
StoreDocumentStatus,
|
StoreDocumentStatus,
|
||||||
} from '../../../common/enum/document.enum';
|
} from '../../../common/enum/document.enum';
|
||||||
|
import { ObservedLeadDto } from './leadTime.dto';
|
||||||
|
|
||||||
export class ComplianceTotalsDto {
|
export class ComplianceTotalsDto {
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
@ -60,4 +61,17 @@ export class UpcomingExpirationDto {
|
|||||||
|
|
||||||
@ApiProperty()
|
@ApiProperty()
|
||||||
renewalLeadDays: number;
|
renewalLeadDays: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description:
|
||||||
|
'Prazo dinâmico: max(catálogo, mediana observada do órgão + margem)',
|
||||||
|
})
|
||||||
|
effectiveLeadDays?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Amostra que informou o prazo; nulo = valeu só o catálogo',
|
||||||
|
type: ObservedLeadDto,
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
observed?: ObservedLeadDto | null;
|
||||||
}
|
}
|
||||||
|
|||||||
44
src/modules/compliance/dto/response/leadTime.dto.ts
Normal file
44
src/modules/compliance/dto/response/leadTime.dto.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
|
||||||
|
import { IssuingBody } from '../../../common/enum/document.enum';
|
||||||
|
|
||||||
|
export class LeadTimeRowDto {
|
||||||
|
@ApiProperty({ enum: IssuingBody })
|
||||||
|
issuingBody: IssuingBody;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'Nulo = agregado do órgão inteiro',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
typeCode: string | null;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Ciclos observados na amostra' })
|
||||||
|
count: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
medianDays: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
p90Days: number;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Prazo do catálogo (piso legal)' })
|
||||||
|
catalogLeadDays: number;
|
||||||
|
|
||||||
|
@ApiProperty({
|
||||||
|
description: 'max(catálogo, mediana + margem) quando a amostra basta',
|
||||||
|
})
|
||||||
|
effectiveLeadDays: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class LeadTimesResponseDto {
|
||||||
|
@ApiProperty({ type: [LeadTimeRowDto] })
|
||||||
|
data: LeadTimeRowDto[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ObservedLeadDto {
|
||||||
|
@ApiProperty()
|
||||||
|
count: number;
|
||||||
|
|
||||||
|
@ApiProperty()
|
||||||
|
medianDays: number;
|
||||||
|
}
|
||||||
174
src/modules/compliance/leadTime.service.ts
Normal file
174
src/modules/compliance/leadTime.service.ts
Normal file
@ -0,0 +1,174 @@
|
|||||||
|
import { Injectable } from '@nestjs/common';
|
||||||
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
|
import { Repository } from 'typeorm';
|
||||||
|
|
||||||
|
import { CatalogService } from '../catalog/catalog.service';
|
||||||
|
import { IssuingBody } from '../common/enum/document.enum';
|
||||||
|
import { LeadTimeRowDto } from './dto/response/leadTime.dto';
|
||||||
|
import { RenewalCycle } from './renewalCycle.entity';
|
||||||
|
|
||||||
|
/** Amostra mínima para confiar na estatística em vez do catálogo. */
|
||||||
|
export const MIN_SAMPLE = 3;
|
||||||
|
|
||||||
|
/** Folga somada à mediana — o órgão não avisa quando resolve atrasar. */
|
||||||
|
export const SAFETY_MARGIN_DAYS = 15;
|
||||||
|
|
||||||
|
export interface ILeadTimeStats {
|
||||||
|
count: number;
|
||||||
|
medianDays: number;
|
||||||
|
p90Days: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ILeadTimeIndex {
|
||||||
|
byType: Map<string, ILeadTimeStats>;
|
||||||
|
byBody: Map<IssuingBody, ILeadTimeStats>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IEffectiveLead {
|
||||||
|
effectiveLeadDays: number;
|
||||||
|
/** A amostra que informou a decisão; nulo quando valeu só o catálogo. */
|
||||||
|
observed: { count: number; medianDays: number } | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const median = (sorted: number[]): number => {
|
||||||
|
const middle = Math.floor(sorted.length / 2);
|
||||||
|
return sorted.length % 2
|
||||||
|
? sorted[middle]
|
||||||
|
: Math.round((sorted[middle - 1] + sorted[middle]) / 2);
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Percentil 90 por nearest-rank — simples e sem interpolação discutível. */
|
||||||
|
const p90 = (sorted: number[]): number =>
|
||||||
|
sorted[Math.max(Math.ceil(sorted.length * 0.9) - 1, 0)];
|
||||||
|
|
||||||
|
const toStats = (days: number[]): ILeadTimeStats => {
|
||||||
|
const sorted = [...days].sort((a, b) => a - b);
|
||||||
|
return { count: sorted.length, medianDays: median(sorted), p90Days: p90(sorted) };
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prazo de renovação dinâmico (Onda 1.2 do plano de IA) — v0, estatística
|
||||||
|
* descritiva, sem ML.
|
||||||
|
*
|
||||||
|
* A régua: `effectiveLeadDays = max(catálogo, mediana observada + 15)` quando
|
||||||
|
* há amostra (>= MIN_SAMPLE ciclos). O catálogo é PISO, nunca teto — o prazo
|
||||||
|
* legal da LO (120 dias) não encolhe porque um órgão andou rápido; mas se a
|
||||||
|
* SEMAD está levando 140 dias, avisar com 120 é avisar tarde.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class LeadTimeService {
|
||||||
|
constructor(
|
||||||
|
@InjectRepository(RenewalCycle)
|
||||||
|
private readonly cycleRepository: Repository<RenewalCycle>,
|
||||||
|
private readonly catalogService: CatalogService,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/** Uma ida ao banco; agregação em memória (amostras são pequenas). */
|
||||||
|
async getIndex(programId: string): Promise<ILeadTimeIndex> {
|
||||||
|
const cycles = await this.cycleRepository.find({ where: { programId } });
|
||||||
|
|
||||||
|
const byTypeDays = new Map<string, number[]>();
|
||||||
|
const byBodyDays = new Map<IssuingBody, number[]>();
|
||||||
|
|
||||||
|
cycles.forEach(cycle => {
|
||||||
|
byTypeDays.set(cycle.typeCode, [
|
||||||
|
...(byTypeDays.get(cycle.typeCode) ?? []),
|
||||||
|
cycle.emissionDays,
|
||||||
|
]);
|
||||||
|
byBodyDays.set(cycle.issuingBody, [
|
||||||
|
...(byBodyDays.get(cycle.issuingBody) ?? []),
|
||||||
|
cycle.emissionDays,
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
byType: new Map(
|
||||||
|
[...byTypeDays.entries()].map(([code, days]) => [code, toStats(days)]),
|
||||||
|
),
|
||||||
|
byBody: new Map(
|
||||||
|
[...byBodyDays.entries()].map(([body, days]) => [body, toStats(days)]),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* O prazo efetivo de UM tipo: amostra do tipo > amostra do órgão > catálogo.
|
||||||
|
* A amostra específica do tipo vence a do órgão porque uma LO não se renova
|
||||||
|
* no ritmo de um CADRI, mesmo sendo o mesmo órgão.
|
||||||
|
*/
|
||||||
|
effectiveLeadFor(
|
||||||
|
type: { code: string; issuingBody: IssuingBody; renewalLeadDays: number },
|
||||||
|
index: ILeadTimeIndex,
|
||||||
|
): IEffectiveLead {
|
||||||
|
const stats =
|
||||||
|
this.usable(index.byType.get(type.code)) ??
|
||||||
|
this.usable(index.byBody.get(type.issuingBody));
|
||||||
|
|
||||||
|
if (!stats) {
|
||||||
|
return { effectiveLeadDays: type.renewalLeadDays, observed: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
effectiveLeadDays: Math.max(
|
||||||
|
type.renewalLeadDays,
|
||||||
|
stats.medianDays + SAFETY_MARGIN_DAYS,
|
||||||
|
),
|
||||||
|
observed: { count: stats.count, medianDays: stats.medianDays },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Tabela para o painel: uma linha por órgão + uma por tipo com amostra. */
|
||||||
|
async getLeadTimes(programId: string): Promise<LeadTimeRowDto[]> {
|
||||||
|
const [index, types] = await Promise.all([
|
||||||
|
this.getIndex(programId),
|
||||||
|
this.catalogService.getActiveTypes(),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const rows: LeadTimeRowDto[] = [];
|
||||||
|
|
||||||
|
index.byBody.forEach((stats, issuingBody) => {
|
||||||
|
// O piso do órgão é o maior prazo entre os tipos dele — conservador
|
||||||
|
const catalogLeadDays = Math.max(
|
||||||
|
0,
|
||||||
|
...types
|
||||||
|
.filter(type => type.issuingBody === issuingBody)
|
||||||
|
.map(type => type.renewalLeadDays),
|
||||||
|
);
|
||||||
|
rows.push({
|
||||||
|
issuingBody,
|
||||||
|
typeCode: null,
|
||||||
|
...stats,
|
||||||
|
catalogLeadDays,
|
||||||
|
effectiveLeadDays:
|
||||||
|
stats.count >= MIN_SAMPLE
|
||||||
|
? Math.max(catalogLeadDays, stats.medianDays + SAFETY_MARGIN_DAYS)
|
||||||
|
: catalogLeadDays,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
index.byType.forEach((stats, typeCode) => {
|
||||||
|
const type = types.find(item => item.code === typeCode);
|
||||||
|
if (!type) return;
|
||||||
|
rows.push({
|
||||||
|
issuingBody: type.issuingBody,
|
||||||
|
typeCode,
|
||||||
|
...stats,
|
||||||
|
catalogLeadDays: type.renewalLeadDays,
|
||||||
|
effectiveLeadDays:
|
||||||
|
stats.count >= MIN_SAMPLE
|
||||||
|
? Math.max(type.renewalLeadDays, stats.medianDays + SAFETY_MARGIN_DAYS)
|
||||||
|
: type.renewalLeadDays,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
return rows.sort(
|
||||||
|
(a, b) =>
|
||||||
|
a.issuingBody.localeCompare(b.issuingBody) ||
|
||||||
|
(a.typeCode ?? '').localeCompare(b.typeCode ?? ''),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private usable(stats?: ILeadTimeStats): ILeadTimeStats | null {
|
||||||
|
return stats && stats.count >= MIN_SAMPLE ? stats : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
44
src/modules/compliance/renewalCycle.entity.ts
Normal file
44
src/modules/compliance/renewalCycle.entity.ts
Normal file
@ -0,0 +1,44 @@
|
|||||||
|
import { ApiProperty } from '@nestjs/swagger';
|
||||||
|
import { Column, Entity } from 'typeorm';
|
||||||
|
|
||||||
|
import { BaseCollection } from '../common/entities/base.entity';
|
||||||
|
import { IssuingBody } from '../common/enum/document.enum';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Um ciclo de renovação observado: do protocolo no órgão até a emissão do
|
||||||
|
* documento novo.
|
||||||
|
*
|
||||||
|
* É a matéria-prima do prazo de renovação dinâmico (Onda 1.2): a mediana de
|
||||||
|
* `emissionDays` por órgão na carteira substitui o chute fixo do catálogo.
|
||||||
|
* Gravado automaticamente no save que zera um protocolo — nunca editado.
|
||||||
|
*/
|
||||||
|
@Entity('renewal_cycle')
|
||||||
|
export class RenewalCycle extends BaseCollection {
|
||||||
|
@ApiProperty({ description: 'Programa (rede) dono do ciclo' })
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
programId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Loja dona do ciclo' })
|
||||||
|
@Column({ type: 'uuid' })
|
||||||
|
storeId: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Código do tipo no catálogo' })
|
||||||
|
@Column({ type: 'varchar', length: 80 })
|
||||||
|
typeCode: string;
|
||||||
|
|
||||||
|
@ApiProperty({ enum: IssuingBody })
|
||||||
|
@Column({ type: 'enum', enum: IssuingBody, enumName: 'issuing_body_enum' })
|
||||||
|
issuingBody: IssuingBody;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Data do protocolo no órgão' })
|
||||||
|
@Column({ type: 'date' })
|
||||||
|
protocolDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Data de emissão do documento renovado' })
|
||||||
|
@Column({ type: 'date' })
|
||||||
|
issuedDate: string;
|
||||||
|
|
||||||
|
@ApiProperty({ description: 'Dias entre protocolo e emissão' })
|
||||||
|
@Column({ type: 'int' })
|
||||||
|
emissionDays: number;
|
||||||
|
}
|
||||||
184
src/modules/document/__tests__/anthropicAnalyzer.spec.ts
Normal file
184
src/modules/document/__tests__/anthropicAnalyzer.spec.ts
Normal file
@ -0,0 +1,184 @@
|
|||||||
|
import { DocumentType } from '../../catalog/documentType.entity';
|
||||||
|
import { AnthropicAnalyzer } from '../analyzer/anthropicAnalyzer';
|
||||||
|
import { KeywordAnalyzer } from '../analyzer/keywordAnalyzer';
|
||||||
|
import { documentAnalyzerFactory } from '../document.module';
|
||||||
|
|
||||||
|
const TODAY = '2026-08-07';
|
||||||
|
|
||||||
|
const type = (partial: Partial<DocumentType>): DocumentType =>
|
||||||
|
({
|
||||||
|
code: 'x',
|
||||||
|
name: 'X',
|
||||||
|
keywords: [],
|
||||||
|
defaultValidityMonths: null,
|
||||||
|
renewalLeadDays: 30,
|
||||||
|
...partial,
|
||||||
|
}) as DocumentType;
|
||||||
|
|
||||||
|
const TYPES = [
|
||||||
|
type({
|
||||||
|
code: 'avcb',
|
||||||
|
name: 'AVCB',
|
||||||
|
keywords: ['avcb', 'bombeiros'],
|
||||||
|
defaultValidityMonths: null,
|
||||||
|
}),
|
||||||
|
type({
|
||||||
|
code: 'afericao-bombas',
|
||||||
|
name: 'Verificação INMETRO',
|
||||||
|
keywords: ['inmetro', 'ipem'],
|
||||||
|
defaultValidityMonths: 12,
|
||||||
|
}),
|
||||||
|
];
|
||||||
|
|
||||||
|
const input = (overrides: Partial<Parameters<AnthropicAnalyzer['analyze']>[0]> = {}) => ({
|
||||||
|
fileName: 'documento.pdf',
|
||||||
|
buffer: Buffer.from('%PDF-1.4 conteudo'),
|
||||||
|
contentType: 'application/pdf',
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Resposta da API de Messages com o JSON do modelo dentro do bloco de texto. */
|
||||||
|
const apiResponse = (json: Record<string, unknown>) => ({
|
||||||
|
ok: true,
|
||||||
|
status: 200,
|
||||||
|
json: async () => ({
|
||||||
|
content: [{ type: 'text', text: `Segue o resultado:\n${JSON.stringify(json)}` }],
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const passPayload = (overrides: Record<string, unknown> = {}) => ({
|
||||||
|
typeCode: 'avcb',
|
||||||
|
issueDate: '2026-01-10',
|
||||||
|
expiryDate: '2029-01-10',
|
||||||
|
documentNumber: 'AVCB-123',
|
||||||
|
confidence: {
|
||||||
|
typeCode: 0.9,
|
||||||
|
issueDate: 0.9,
|
||||||
|
expiryDate: 0.85,
|
||||||
|
documentNumber: 0.8,
|
||||||
|
},
|
||||||
|
quotes: {
|
||||||
|
issueDate: 'emitido em 10 de janeiro de 2026',
|
||||||
|
expiryDate: 'válido até 10/01/2029',
|
||||||
|
documentNumber: 'AVCB nº AVCB-123',
|
||||||
|
},
|
||||||
|
...overrides,
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('AnthropicAnalyzer', () => {
|
||||||
|
const originalFetch = global.fetch;
|
||||||
|
let fetchMock: jest.Mock;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||||
|
fetchMock = jest.fn();
|
||||||
|
global.fetch = fetchMock as unknown as typeof fetch;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
global.fetch = originalFetch;
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
const analyzer = () => new AnthropicAnalyzer(new KeywordAnalyzer());
|
||||||
|
|
||||||
|
it('passadas convergentes: campos, confiança com bônus e evidência', async () => {
|
||||||
|
fetchMock.mockResolvedValue(apiResponse(passPayload()));
|
||||||
|
|
||||||
|
const result = await analyzer().analyze(input(), TYPES, TODAY);
|
||||||
|
|
||||||
|
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||||
|
expect(result.suggestedTypeCode).toBe('avcb');
|
||||||
|
expect(result.detectedFields).toEqual({
|
||||||
|
issueDate: '2026-01-10',
|
||||||
|
expiryDate: '2029-01-10',
|
||||||
|
documentNumber: 'AVCB-123',
|
||||||
|
});
|
||||||
|
// bônus de concordância sobre a menor autoavaliação, teto 0,98
|
||||||
|
expect(result.fieldConfidences?.issueDate).toBeCloseTo(0.98, 5);
|
||||||
|
expect(result.fieldConfidences?.expiryDate).toBeCloseTo(0.95, 5);
|
||||||
|
expect(result.evidence?.expiryDate).toBe('válido até 10/01/2029');
|
||||||
|
expect(result.warnings).not.toContain('dateMismatch');
|
||||||
|
expect(result.confidence).toBeLessThanOrEqual(0.98);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('datas divergentes entre as passadas viram null com dateMismatch', async () => {
|
||||||
|
fetchMock
|
||||||
|
.mockResolvedValueOnce(apiResponse(passPayload()))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
apiResponse(passPayload({ expiryDate: '2028-01-10' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await analyzer().analyze(input(), TYPES, TODAY);
|
||||||
|
|
||||||
|
expect(result.detectedFields.expiryDate).toBeNull();
|
||||||
|
expect(result.detectedFields.issueDate).toBe('2026-01-10');
|
||||||
|
expect(result.fieldConfidences?.expiryDate).toBeLessThanOrEqual(0.5);
|
||||||
|
expect(result.warnings).toContain('dateMismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('erro da API cai na heurística com llmUnavailable', async () => {
|
||||||
|
fetchMock.mockResolvedValue({ ok: false, status: 529, json: async () => ({}) });
|
||||||
|
|
||||||
|
const result = await analyzer().analyze(
|
||||||
|
input({ fileName: 'avcb-bombeiros-2026.pdf' }),
|
||||||
|
TYPES,
|
||||||
|
TODAY,
|
||||||
|
);
|
||||||
|
|
||||||
|
// resultado é o da heurística (nome do arquivo reconhece o AVCB)
|
||||||
|
expect(result.suggestedTypeCode).toBe('avcb');
|
||||||
|
expect(result.warnings).toContain('llmUnavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('typeCode fora do catálogo vira null com typeNotRecognized', async () => {
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
apiResponse(passPayload({ typeCode: 'tipo-inventado' })),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await analyzer().analyze(input(), TYPES, TODAY);
|
||||||
|
|
||||||
|
expect(result.suggestedTypeCode).toBeNull();
|
||||||
|
expect(result.warnings).toContain('typeNotRecognized');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('arquivo grande demais nem chama a API', async () => {
|
||||||
|
const result = await analyzer().analyze(
|
||||||
|
input({ buffer: Buffer.alloc(11 * 1024 * 1024) }),
|
||||||
|
TYPES,
|
||||||
|
TODAY,
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(fetchMock).not.toHaveBeenCalled();
|
||||||
|
expect(result.warnings).toContain('llmUnavailable');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tipo sem validade padrão e sem data extraída avisa validityVariesByState', async () => {
|
||||||
|
fetchMock.mockResolvedValue(
|
||||||
|
apiResponse(
|
||||||
|
passPayload({ expiryDate: null, quotes: { issueDate: 'emitido em 10/01/2026' } }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const result = await analyzer().analyze(input(), TYPES, TODAY);
|
||||||
|
|
||||||
|
expect(result.detectedFields.expiryDate).toBeNull();
|
||||||
|
expect(result.warnings).toContain('validityVariesByState');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('documentAnalyzerFactory', () => {
|
||||||
|
afterEach(() => {
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
});
|
||||||
|
|
||||||
|
it('sem chave devolve a heurística', () => {
|
||||||
|
delete process.env.ANTHROPIC_API_KEY;
|
||||||
|
expect(documentAnalyzerFactory()).toBeInstanceOf(KeywordAnalyzer);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('com chave devolve o analisador por LLM', () => {
|
||||||
|
process.env.ANTHROPIC_API_KEY = 'test-key';
|
||||||
|
expect(documentAnalyzerFactory()).toBeInstanceOf(AnthropicAnalyzer);
|
||||||
|
});
|
||||||
|
});
|
||||||
@ -23,11 +23,18 @@ const futureDate = (days: number): string => {
|
|||||||
const avcbType = {
|
const avcbType = {
|
||||||
code: 'avcb',
|
code: 'avcb',
|
||||||
name: 'AVCB',
|
name: 'AVCB',
|
||||||
|
issuingBody: 'corpoDeBombeiros',
|
||||||
renewalLeadDays: 60,
|
renewalLeadDays: 60,
|
||||||
keywords: ['avcb'],
|
keywords: ['avcb'],
|
||||||
defaultValidityMonths: null,
|
defaultValidityMonths: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
/** Chamadas de save do manager para uma entidade (pelo nome da classe). */
|
||||||
|
const savesOf = (manager: Record<string, any>, entityName: string) =>
|
||||||
|
(manager.save as jest.Mock).mock.calls.filter(
|
||||||
|
([entity]: any[]) => entity?.name === entityName,
|
||||||
|
);
|
||||||
|
|
||||||
interface ISetup {
|
interface ISetup {
|
||||||
documents?: any[];
|
documents?: any[];
|
||||||
manager?: Record<string, any>;
|
manager?: Record<string, any>;
|
||||||
@ -190,6 +197,88 @@ describe('DocumentService.save', () => {
|
|||||||
expect(existing.renewalProtocolDate).toBeNull();
|
expect(existing.renewalProtocolDate).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('renovação com protocolo prévio grava o ciclo protocolo→emissão', async () => {
|
||||||
|
const existing = {
|
||||||
|
id: DOC_ID,
|
||||||
|
programId: PROGRAM_ID,
|
||||||
|
storeId: STORE_ID,
|
||||||
|
typeCode: 'avcb',
|
||||||
|
expiryDate: futureDate(-5),
|
||||||
|
renewalProtocolNumber: 'PROT-1',
|
||||||
|
renewalProtocolDate: '2026-07-01',
|
||||||
|
};
|
||||||
|
const manager = entityManagerMock({
|
||||||
|
findOne: jest.fn().mockResolvedValue(existing),
|
||||||
|
});
|
||||||
|
const { service, documentRepository } = setup({ manager });
|
||||||
|
documentRepository.findOne = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ...existing, versions: [] });
|
||||||
|
|
||||||
|
await service.save(input); // issueDate 2026-08-01
|
||||||
|
|
||||||
|
const cycleSaves = savesOf(manager, 'RenewalCycle');
|
||||||
|
expect(cycleSaves).toHaveLength(1);
|
||||||
|
expect(cycleSaves[0][1]).toMatchObject({
|
||||||
|
typeCode: 'avcb',
|
||||||
|
issuingBody: 'corpoDeBombeiros',
|
||||||
|
protocolDate: '2026-07-01',
|
||||||
|
issuedDate: '2026-08-01',
|
||||||
|
emissionDays: 31,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('primeira versão ou renovação SEM protocolo não gera ciclo', async () => {
|
||||||
|
const existing = {
|
||||||
|
id: DOC_ID,
|
||||||
|
programId: PROGRAM_ID,
|
||||||
|
storeId: STORE_ID,
|
||||||
|
typeCode: 'avcb',
|
||||||
|
expiryDate: futureDate(-5),
|
||||||
|
renewalProtocolNumber: null,
|
||||||
|
renewalProtocolDate: null,
|
||||||
|
};
|
||||||
|
const manager = entityManagerMock({
|
||||||
|
findOne: jest.fn().mockResolvedValue(existing),
|
||||||
|
});
|
||||||
|
const { service, documentRepository } = setup({ manager });
|
||||||
|
documentRepository.findOne = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ...existing, versions: [] });
|
||||||
|
|
||||||
|
await service.save(input);
|
||||||
|
|
||||||
|
expect(savesOf(manager, 'RenewalCycle')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ciclo implausível (negativo ou > 2 anos) é descartado com log', async () => {
|
||||||
|
const existing = {
|
||||||
|
id: DOC_ID,
|
||||||
|
programId: PROGRAM_ID,
|
||||||
|
storeId: STORE_ID,
|
||||||
|
typeCode: 'avcb',
|
||||||
|
expiryDate: futureDate(-5),
|
||||||
|
renewalProtocolNumber: 'PROT-VELHO',
|
||||||
|
renewalProtocolDate: '2020-01-01', // > 2 anos atrás: protocolo esquecido
|
||||||
|
};
|
||||||
|
const manager = entityManagerMock({
|
||||||
|
findOne: jest.fn().mockResolvedValue(existing),
|
||||||
|
});
|
||||||
|
const { service, documentRepository } = setup({ manager });
|
||||||
|
documentRepository.findOne = jest
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ...existing, versions: [] });
|
||||||
|
|
||||||
|
await service.save(input);
|
||||||
|
expect(savesOf(manager, 'RenewalCycle')).toHaveLength(0);
|
||||||
|
|
||||||
|
// protocolo DEPOIS da emissão (intervalo negativo) também não vale
|
||||||
|
existing.renewalProtocolNumber = 'PROT-2';
|
||||||
|
existing.renewalProtocolDate = '2026-09-01';
|
||||||
|
await service.save(input);
|
||||||
|
expect(savesOf(manager, 'RenewalCycle')).toHaveLength(0);
|
||||||
|
});
|
||||||
|
|
||||||
it('recusa tipo inexistente antes de gravar qualquer coisa', async () => {
|
it('recusa tipo inexistente antes de gravar qualquer coisa', async () => {
|
||||||
const { service, catalogService, manager } = setup();
|
const { service, catalogService, manager } = setup();
|
||||||
catalogService.getByCode = jest.fn().mockRejectedValue(new Error('TYPE_NOT_FOUND'));
|
catalogService.getByCode = jest.fn().mockRejectedValue(new Error('TYPE_NOT_FOUND'));
|
||||||
|
|||||||
327
src/modules/document/analyzer/anthropicAnalyzer.ts
Normal file
327
src/modules/document/analyzer/anthropicAnalyzer.ts
Normal file
@ -0,0 +1,327 @@
|
|||||||
|
import { Injectable, Logger } from '@nestjs/common';
|
||||||
|
|
||||||
|
import { DocumentType } from '../../catalog/documentType.entity';
|
||||||
|
import {
|
||||||
|
DocumentAnalyzer,
|
||||||
|
IAnalysisResult,
|
||||||
|
IAnalyzerInput,
|
||||||
|
} from './documentAnalyzer.interface';
|
||||||
|
|
||||||
|
const API_URL = 'https://api.anthropic.com/v1/messages';
|
||||||
|
const API_VERSION = '2023-06-01';
|
||||||
|
const DEFAULT_MODEL = 'claude-haiku-4-5-20251001';
|
||||||
|
const TIMEOUT_MS = 60_000;
|
||||||
|
const MAX_FILE_BYTES = 10 * 1024 * 1024;
|
||||||
|
const SUPPORTED_TYPES = ['application/pdf', 'image/png', 'image/jpeg'];
|
||||||
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
|
||||||
|
|
||||||
|
/** O que UMA passada do modelo devolve, já validada. */
|
||||||
|
interface IPassResult {
|
||||||
|
typeCode: string | null;
|
||||||
|
issueDate: string | null;
|
||||||
|
expiryDate: string | null;
|
||||||
|
documentNumber: string | null;
|
||||||
|
confidence: {
|
||||||
|
typeCode?: number;
|
||||||
|
issueDate?: number;
|
||||||
|
expiryDate?: number;
|
||||||
|
documentNumber?: number;
|
||||||
|
};
|
||||||
|
quotes: {
|
||||||
|
issueDate?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
documentNumber?: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Análise por LLM (Onda 1.1 do PLANO-DOCUMENTOS-IA).
|
||||||
|
*
|
||||||
|
* Princípios do plano, na ordem em que aparecem no código:
|
||||||
|
*
|
||||||
|
* - **IA preenche, humano confirma**: confiança nunca passa de 0,98 e todo
|
||||||
|
* campo carrega a citação do trecho de onde saiu, para a UI de conferência.
|
||||||
|
* - **Dupla passada em datas**: duas chamadas independentes com fraseados
|
||||||
|
* diferentes; data que diverge entre elas vira null + warning
|
||||||
|
* `dateMismatch` — datas são o campo onde alucinação mais dói.
|
||||||
|
* - **A análise nunca falha por causa do LLM**: qualquer erro (API fora,
|
||||||
|
* JSON inválido, arquivo grande demais) cai no analisador heurístico com o
|
||||||
|
* warning `llmUnavailable`.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class AnthropicAnalyzer implements DocumentAnalyzer {
|
||||||
|
private readonly logger = new Logger(AnthropicAnalyzer.name);
|
||||||
|
|
||||||
|
constructor(private readonly fallback: DocumentAnalyzer) {}
|
||||||
|
|
||||||
|
private get model(): string {
|
||||||
|
return process.env.DOCUMENTS_LLM_MODEL || DEFAULT_MODEL;
|
||||||
|
}
|
||||||
|
|
||||||
|
async analyze(
|
||||||
|
input: IAnalyzerInput,
|
||||||
|
types: DocumentType[],
|
||||||
|
today: string,
|
||||||
|
): Promise<IAnalysisResult> {
|
||||||
|
const unsupported =
|
||||||
|
!input.buffer ||
|
||||||
|
!input.contentType ||
|
||||||
|
!SUPPORTED_TYPES.includes(input.contentType) ||
|
||||||
|
input.buffer.length > MAX_FILE_BYTES;
|
||||||
|
|
||||||
|
if (unsupported) {
|
||||||
|
return this.fallbackWith(input, types, today);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Passadas independentes: fraseados diferentes para não repetir o
|
||||||
|
// mesmo erro duas vezes. A concordância entre elas é o que dá confiança.
|
||||||
|
const [first, second] = await Promise.all([
|
||||||
|
this.extract(input, types, 'primary'),
|
||||||
|
this.extract(input, types, 'verification'),
|
||||||
|
]);
|
||||||
|
return this.merge(first, second, types);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn(
|
||||||
|
`Análise por LLM indisponível (${(error as Error).message}); usando heurística.`,
|
||||||
|
);
|
||||||
|
return this.fallbackWith(input, types, today);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ------------------------------------------------------------------ */
|
||||||
|
|
||||||
|
private async fallbackWith(
|
||||||
|
input: IAnalyzerInput,
|
||||||
|
types: DocumentType[],
|
||||||
|
today: string,
|
||||||
|
): Promise<IAnalysisResult> {
|
||||||
|
const result = await this.fallback.analyze(input, types, today);
|
||||||
|
return { ...result, warnings: [...result.warnings, 'llmUnavailable'] };
|
||||||
|
}
|
||||||
|
|
||||||
|
private async extract(
|
||||||
|
input: IAnalyzerInput,
|
||||||
|
types: DocumentType[],
|
||||||
|
variant: 'primary' | 'verification',
|
||||||
|
): Promise<IPassResult> {
|
||||||
|
const contentBlock =
|
||||||
|
input.contentType === 'application/pdf'
|
||||||
|
? {
|
||||||
|
type: 'document',
|
||||||
|
source: {
|
||||||
|
type: 'base64',
|
||||||
|
media_type: 'application/pdf',
|
||||||
|
data: input.buffer!.toString('base64'),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
type: 'image',
|
||||||
|
source: {
|
||||||
|
type: 'base64',
|
||||||
|
media_type: input.contentType,
|
||||||
|
data: input.buffer!.toString('base64'),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const response = await fetch(API_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
'content-type': 'application/json',
|
||||||
|
'x-api-key': process.env.ANTHROPIC_API_KEY!,
|
||||||
|
'anthropic-version': API_VERSION,
|
||||||
|
},
|
||||||
|
body: JSON.stringify({
|
||||||
|
model: this.model,
|
||||||
|
max_tokens: 1024,
|
||||||
|
temperature: 0,
|
||||||
|
system: this.systemPrompt(types),
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
role: 'user',
|
||||||
|
content: [contentBlock, { type: 'text', text: this.instruction(variant) }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`Anthropic API respondeu ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = (await response.json()) as {
|
||||||
|
content?: { type: string; text?: string }[];
|
||||||
|
};
|
||||||
|
const text = (payload.content ?? [])
|
||||||
|
.filter(block => block.type === 'text')
|
||||||
|
.map(block => block.text ?? '')
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
return this.parse(text);
|
||||||
|
}
|
||||||
|
|
||||||
|
private systemPrompt(types: DocumentType[]): string {
|
||||||
|
const catalog = types
|
||||||
|
.map(
|
||||||
|
type =>
|
||||||
|
`- ${type.code}: ${type.name} (palavras típicas: ${(type.keywords ?? []).join(', ')})`,
|
||||||
|
)
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
return [
|
||||||
|
'Você extrai dados de documentos regulatórios de postos de combustíveis brasileiros (licenças, alvarás, laudos, certificados).',
|
||||||
|
'Tipos possíveis (use EXATAMENTE um destes códigos, ou null se nenhum servir):',
|
||||||
|
catalog,
|
||||||
|
'',
|
||||||
|
'Responda SOMENTE com um objeto JSON neste formato, sem comentários:',
|
||||||
|
'{"typeCode": string|null, "issueDate": "YYYY-MM-DD"|null, "expiryDate": "YYYY-MM-DD"|null, "documentNumber": string|null, "confidence": {"typeCode": 0-1, "issueDate": 0-1, "expiryDate": 0-1, "documentNumber": 0-1}, "quotes": {"issueDate": string, "expiryDate": string, "documentNumber": string}}',
|
||||||
|
'',
|
||||||
|
'Regras:',
|
||||||
|
'- Se um dado não estiver legível no documento, devolva null nesse campo — NUNCA invente.',
|
||||||
|
'- Datas sempre em ISO YYYY-MM-DD.',
|
||||||
|
'- Em "quotes", copie o trecho LITERAL do documento de onde cada valor saiu (omita a chave se o valor for null).',
|
||||||
|
'- "confidence" é sua avaliação honesta de 0 a 1 por campo.',
|
||||||
|
].join('\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
private instruction(variant: 'primary' | 'verification'): string {
|
||||||
|
return variant === 'primary'
|
||||||
|
? 'Extraia os dados deste documento conforme o formato combinado.'
|
||||||
|
: 'Releia o documento com atenção às datas de emissão e de validade e extraia os dados no formato combinado. Confira cada data contra o texto antes de responder.';
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Parse tolerante: pega o primeiro bloco {...} e valida campo a campo. */
|
||||||
|
private parse(text: string): IPassResult {
|
||||||
|
const match = text.match(/\{[\s\S]*\}/);
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('resposta do modelo sem JSON');
|
||||||
|
}
|
||||||
|
const raw = JSON.parse(match[0]) as Record<string, unknown>;
|
||||||
|
|
||||||
|
const asDate = (value: unknown): string | null =>
|
||||||
|
typeof value === 'string' && DATE_PATTERN.test(value) ? value : null;
|
||||||
|
const asString = (value: unknown): string | null =>
|
||||||
|
typeof value === 'string' && value.trim() ? value.trim() : null;
|
||||||
|
const asConfidence = (value: unknown): number | undefined =>
|
||||||
|
typeof value === 'number' && value >= 0 && value <= 1
|
||||||
|
? value
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const confidence = (raw.confidence ?? {}) as Record<string, unknown>;
|
||||||
|
const quotes = (raw.quotes ?? {}) as Record<string, unknown>;
|
||||||
|
|
||||||
|
return {
|
||||||
|
typeCode: asString(raw.typeCode),
|
||||||
|
issueDate: asDate(raw.issueDate),
|
||||||
|
expiryDate: asDate(raw.expiryDate),
|
||||||
|
documentNumber: asString(raw.documentNumber),
|
||||||
|
confidence: {
|
||||||
|
typeCode: asConfidence(confidence.typeCode),
|
||||||
|
issueDate: asConfidence(confidence.issueDate),
|
||||||
|
expiryDate: asConfidence(confidence.expiryDate),
|
||||||
|
documentNumber: asConfidence(confidence.documentNumber),
|
||||||
|
},
|
||||||
|
quotes: {
|
||||||
|
issueDate: asString(quotes.issueDate) ?? undefined,
|
||||||
|
expiryDate: asString(quotes.expiryDate) ?? undefined,
|
||||||
|
documentNumber: asString(quotes.documentNumber) ?? undefined,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private merge(
|
||||||
|
first: IPassResult,
|
||||||
|
second: IPassResult,
|
||||||
|
types: DocumentType[],
|
||||||
|
): IAnalysisResult {
|
||||||
|
const warnings: string[] = [];
|
||||||
|
|
||||||
|
// typeCode: só vale se existe no catálogo; primeira passada manda,
|
||||||
|
// segunda cobre quando a primeira falhou
|
||||||
|
const validCode = (code: string | null): string | null =>
|
||||||
|
code && types.some(type => type.code === code) ? code : null;
|
||||||
|
const typeCode = validCode(first.typeCode) ?? validCode(second.typeCode);
|
||||||
|
if (!typeCode) warnings.push('typeNotRecognized');
|
||||||
|
|
||||||
|
const mergeDate = (
|
||||||
|
field: 'issueDate' | 'expiryDate',
|
||||||
|
): { value: string | null; confidence: number } => {
|
||||||
|
const a = first[field];
|
||||||
|
const b = second[field];
|
||||||
|
if (a !== b) {
|
||||||
|
// Divergência entre passadas: melhor campo vazio que data errada
|
||||||
|
if (!warnings.includes('dateMismatch')) warnings.push('dateMismatch');
|
||||||
|
return { value: null, confidence: 0.5 };
|
||||||
|
}
|
||||||
|
if (a === null) return { value: null, confidence: 0.5 };
|
||||||
|
const self = Math.min(
|
||||||
|
first.confidence[field] ?? 0.8,
|
||||||
|
second.confidence[field] ?? 0.8,
|
||||||
|
);
|
||||||
|
// Concordância entre passadas independentes vale um bônus — mas o
|
||||||
|
// humano sempre confirma, então nunca 1.0
|
||||||
|
return { value: a, confidence: Math.min(self + 0.1, 0.98) };
|
||||||
|
};
|
||||||
|
|
||||||
|
const issue = mergeDate('issueDate');
|
||||||
|
const expiry = mergeDate('expiryDate');
|
||||||
|
|
||||||
|
const numberAgrees = first.documentNumber === second.documentNumber;
|
||||||
|
const documentNumber = first.documentNumber ?? second.documentNumber;
|
||||||
|
const numberConfidence = documentNumber
|
||||||
|
? numberAgrees
|
||||||
|
? Math.min(
|
||||||
|
Math.min(
|
||||||
|
first.confidence.documentNumber ?? 0.8,
|
||||||
|
second.confidence.documentNumber ?? 0.8,
|
||||||
|
) + 0.1,
|
||||||
|
0.98,
|
||||||
|
)
|
||||||
|
: 0.5
|
||||||
|
: 0.5;
|
||||||
|
|
||||||
|
const suggestedType = types.find(type => type.code === typeCode);
|
||||||
|
if (suggestedType && !suggestedType.defaultValidityMonths && !expiry.value) {
|
||||||
|
warnings.push('validityVariesByState');
|
||||||
|
}
|
||||||
|
|
||||||
|
const fieldConfidences = {
|
||||||
|
issueDate: issue.confidence,
|
||||||
|
expiryDate: expiry.confidence,
|
||||||
|
documentNumber: numberConfidence,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (
|
||||||
|
Object.values(fieldConfidences).some(value => value < 0.7) ||
|
||||||
|
(typeCode && (first.confidence.typeCode ?? 1) < 0.7)
|
||||||
|
) {
|
||||||
|
warnings.push('lowConfidence');
|
||||||
|
}
|
||||||
|
|
||||||
|
const typeConfidence = typeCode
|
||||||
|
? Math.min(first.confidence.typeCode ?? 0.85, 0.98)
|
||||||
|
: 0.2;
|
||||||
|
|
||||||
|
return {
|
||||||
|
suggestedTypeCode: typeCode,
|
||||||
|
confidence: Math.min(
|
||||||
|
(typeConfidence + issue.confidence + expiry.confidence) / 3,
|
||||||
|
0.98,
|
||||||
|
),
|
||||||
|
detectedFields: {
|
||||||
|
issueDate: issue.value,
|
||||||
|
expiryDate: expiry.value,
|
||||||
|
documentNumber,
|
||||||
|
},
|
||||||
|
fieldConfidences,
|
||||||
|
evidence: {
|
||||||
|
issueDate: first.quotes.issueDate ?? second.quotes.issueDate,
|
||||||
|
expiryDate: first.quotes.expiryDate ?? second.quotes.expiryDate,
|
||||||
|
documentNumber:
|
||||||
|
first.quotes.documentNumber ?? second.quotes.documentNumber,
|
||||||
|
},
|
||||||
|
warnings,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -8,6 +8,18 @@ export interface IAnalysisResult {
|
|||||||
expiryDate?: string | null;
|
expiryDate?: string | null;
|
||||||
documentNumber?: string | null;
|
documentNumber?: string | null;
|
||||||
};
|
};
|
||||||
|
/** Confiança 0–1 por campo — a UI destaca o que precisa de conferência. */
|
||||||
|
fieldConfidences?: {
|
||||||
|
issueDate?: number;
|
||||||
|
expiryDate?: number;
|
||||||
|
documentNumber?: number;
|
||||||
|
} | null;
|
||||||
|
/** Citação literal do trecho do documento de onde cada campo saiu. */
|
||||||
|
evidence?: {
|
||||||
|
issueDate?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
documentNumber?: string;
|
||||||
|
} | null;
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -3,13 +3,26 @@ import { Module } from '@nestjs/common';
|
|||||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||||
|
|
||||||
import { CatalogModule } from '../catalog/catalog.module';
|
import { CatalogModule } from '../catalog/catalog.module';
|
||||||
import { DOCUMENT_ANALYZER } from './analyzer/documentAnalyzer.interface';
|
import { AnthropicAnalyzer } from './analyzer/anthropicAnalyzer';
|
||||||
|
import {
|
||||||
|
DocumentAnalyzer,
|
||||||
|
DOCUMENT_ANALYZER,
|
||||||
|
} from './analyzer/documentAnalyzer.interface';
|
||||||
import { KeywordAnalyzer } from './analyzer/keywordAnalyzer';
|
import { KeywordAnalyzer } from './analyzer/keywordAnalyzer';
|
||||||
import { DocumentController } from './document.controller';
|
import { DocumentController } from './document.controller';
|
||||||
import { DocumentService } from './document.service';
|
import { DocumentService } from './document.service';
|
||||||
import { DocumentVersion } from './documentVersion.entity';
|
import { DocumentVersion } from './documentVersion.entity';
|
||||||
import { StoreDocument } from './storeDocument.entity';
|
import { StoreDocument } from './storeDocument.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Com ANTHROPIC_API_KEY a análise é por LLM (com a heurística de fallback
|
||||||
|
* dentro dela); sem a chave, o comportamento v1 fica intacto.
|
||||||
|
*/
|
||||||
|
export const documentAnalyzerFactory = (): DocumentAnalyzer =>
|
||||||
|
process.env.ANTHROPIC_API_KEY
|
||||||
|
? new AnthropicAnalyzer(new KeywordAnalyzer())
|
||||||
|
: new KeywordAnalyzer();
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
TypeOrmModule.forFeature([StoreDocument, DocumentVersion]),
|
TypeOrmModule.forFeature([StoreDocument, DocumentVersion]),
|
||||||
@ -19,8 +32,7 @@ import { StoreDocument } from './storeDocument.entity';
|
|||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
DocumentService,
|
DocumentService,
|
||||||
// Ponto de troca da análise: v1.1 substitui por OCR/LLM sem tocar no resto
|
{ provide: DOCUMENT_ANALYZER, useFactory: documentAnalyzerFactory },
|
||||||
{ provide: DOCUMENT_ANALYZER, useClass: KeywordAnalyzer },
|
|
||||||
],
|
],
|
||||||
controllers: [DocumentController],
|
controllers: [DocumentController],
|
||||||
exports: [DocumentService],
|
exports: [DocumentService],
|
||||||
|
|||||||
@ -1,11 +1,16 @@
|
|||||||
import { MappedException } from '@clubpetrodev/nestjs-mapped-exception';
|
import { MappedException } from '@clubpetrodev/nestjs-mapped-exception';
|
||||||
import { Inject, Injectable } from '@nestjs/common';
|
import { Inject, Injectable, Logger } from '@nestjs/common';
|
||||||
import { InjectRepository } from '@nestjs/typeorm';
|
import { InjectRepository } from '@nestjs/typeorm';
|
||||||
import { DataSource, Repository } from 'typeorm';
|
import { DataSource, Repository } from 'typeorm';
|
||||||
|
|
||||||
import { CatalogService } from '../catalog/catalog.service';
|
import { CatalogService } from '../catalog/catalog.service';
|
||||||
import { StorageService } from '../common/storage/storage.service';
|
import { StorageService } from '../common/storage/storage.service';
|
||||||
import { resolveStatus, todayKey } from '../common/utils/status.util';
|
import {
|
||||||
|
daysBetween,
|
||||||
|
resolveStatus,
|
||||||
|
todayKey,
|
||||||
|
} from '../common/utils/status.util';
|
||||||
|
import { RenewalCycle } from '../compliance/renewalCycle.entity';
|
||||||
import {
|
import {
|
||||||
DOCUMENT_ANALYZER,
|
DOCUMENT_ANALYZER,
|
||||||
DocumentAnalyzer,
|
DocumentAnalyzer,
|
||||||
@ -36,8 +41,13 @@ export interface ISaveDocumentInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Ciclo acima disso é dado sujo (protocolo esquecido), não órgão lento. */
|
||||||
|
const MAX_PLAUSIBLE_EMISSION_DAYS = 730;
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class DocumentService {
|
export class DocumentService {
|
||||||
|
private readonly logger = new Logger(DocumentService.name);
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
@InjectRepository(StoreDocument)
|
@InjectRepository(StoreDocument)
|
||||||
private readonly documentRepository: Repository<StoreDocument>,
|
private readonly documentRepository: Repository<StoreDocument>,
|
||||||
@ -112,6 +122,12 @@ export class DocumentService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Se havia protocolo, esta versão nova é a renovação saindo do órgão —
|
||||||
|
// o intervalo protocolo → emissão vira amostra do prazo dinâmico.
|
||||||
|
const previousProtocolDate = document.renewalProtocolNumber
|
||||||
|
? document.renewalProtocolDate ?? null
|
||||||
|
: null;
|
||||||
|
|
||||||
document.issueDate = input.issueDate;
|
document.issueDate = input.issueDate;
|
||||||
document.expiryDate = input.expiryDate ?? null;
|
document.expiryDate = input.expiryDate ?? null;
|
||||||
document.documentNumber =
|
document.documentNumber =
|
||||||
@ -124,6 +140,28 @@ export class DocumentService {
|
|||||||
|
|
||||||
document = await manager.save(StoreDocument, document);
|
document = await manager.save(StoreDocument, document);
|
||||||
|
|
||||||
|
if (previousProtocolDate) {
|
||||||
|
const emissionDays = daysBetween(previousProtocolDate, input.issueDate);
|
||||||
|
if (emissionDays >= 0 && emissionDays <= MAX_PLAUSIBLE_EMISSION_DAYS) {
|
||||||
|
await manager.save(
|
||||||
|
RenewalCycle,
|
||||||
|
manager.create(RenewalCycle, {
|
||||||
|
programId: input.programId,
|
||||||
|
storeId: input.storeId,
|
||||||
|
typeCode: input.typeCode,
|
||||||
|
issuingBody: type.issuingBody,
|
||||||
|
protocolDate: previousProtocolDate,
|
||||||
|
issuedDate: input.issueDate,
|
||||||
|
emissionDays,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
this.logger.warn(
|
||||||
|
`Ciclo de renovação descartado (${input.typeCode}): ${emissionDays} dias entre protocolo e emissão não é plausível`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const storagePath = this.storageService.buildPath({
|
const storagePath = this.storageService.buildPath({
|
||||||
programId: input.programId,
|
programId: input.programId,
|
||||||
storeId: input.storeId,
|
storeId: input.storeId,
|
||||||
|
|||||||
@ -103,6 +103,26 @@ export class AnalysisResultResponseDto {
|
|||||||
documentNumber?: string | null;
|
documentNumber?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Confiança 0–1 por campo (análise por LLM)',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
fieldConfidences?: {
|
||||||
|
issueDate?: number;
|
||||||
|
expiryDate?: number;
|
||||||
|
documentNumber?: number;
|
||||||
|
} | null;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
description: 'Trecho literal do documento de onde cada campo saiu',
|
||||||
|
nullable: true,
|
||||||
|
})
|
||||||
|
evidence?: {
|
||||||
|
issueDate?: string;
|
||||||
|
expiryDate?: string;
|
||||||
|
documentNumber?: string;
|
||||||
|
} | null;
|
||||||
|
|
||||||
@ApiProperty({ type: [String] })
|
@ApiProperty({ type: [String] })
|
||||||
warnings: string[];
|
warnings: string[];
|
||||||
}
|
}
|
||||||
|
|||||||
98
src/scripts/seed-demo-cycles.ts
Normal file
98
src/scripts/seed-demo-cycles.ts
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
/* eslint-disable no-console */
|
||||||
|
import datasource from '../ormconfig';
|
||||||
|
import { IssuingBody } from '../modules/common/enum/document.enum';
|
||||||
|
import { RenewalCycle } from '../modules/compliance/renewalCycle.entity';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Seed de demonstração do prazo dinâmico (LAB).
|
||||||
|
*
|
||||||
|
* Gera ciclos protocolo→emissão plausíveis por órgão para o
|
||||||
|
* `/compliance/lead-times` ter o que mostrar antes de existir histórico real.
|
||||||
|
* Determinístico (LCG com semente fixa): rodar duas vezes limpa e regrava.
|
||||||
|
*
|
||||||
|
* Uso: ts-node -r tsconfig-paths/register src/scripts/seed-demo-cycles.ts <programId> [storeId]
|
||||||
|
*/
|
||||||
|
|
||||||
|
const PROFILE: Array<{
|
||||||
|
issuingBody: IssuingBody;
|
||||||
|
typeCode: string;
|
||||||
|
medianDays: number;
|
||||||
|
spreadDays: number;
|
||||||
|
samples: number;
|
||||||
|
}> = [
|
||||||
|
// Órgão ambiental é o caso-motivo da feature: mediana ACIMA dos 120 do catálogo
|
||||||
|
{ issuingBody: IssuingBody.OrgaoAmbiental, typeCode: 'licenca-operacao', medianDays: 140, spreadDays: 35, samples: 6 },
|
||||||
|
{ issuingBody: IssuingBody.CorpoDeBombeiros, typeCode: 'avcb', medianDays: 45, spreadDays: 15, samples: 5 },
|
||||||
|
{ issuingBody: IssuingBody.Prefeitura, typeCode: 'alvara-funcionamento', medianDays: 30, spreadDays: 12, samples: 5 },
|
||||||
|
{ issuingBody: IssuingBody.Ipem, typeCode: 'afericao-bombas', medianDays: 12, spreadDays: 6, samples: 4 },
|
||||||
|
// Amostra pequena de propósito: fica ABAIXO de MIN_SAMPLE e o catálogo vale
|
||||||
|
{ issuingBody: IssuingBody.VigilanciaSanitaria, typeCode: 'alvara-sanitario', medianDays: 25, spreadDays: 8, samples: 2 },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** LCG determinístico — o seed precisa dar o mesmo banco em toda máquina. */
|
||||||
|
const makeRandom = (seed: number) => {
|
||||||
|
let state = seed;
|
||||||
|
return () => {
|
||||||
|
state = (state * 48271) % 2147483647;
|
||||||
|
return state / 2147483647;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const dateKey = (date: Date): string => date.toISOString().slice(0, 10);
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const [programId, storeId] = process.argv.slice(2);
|
||||||
|
if (!programId) {
|
||||||
|
console.error('Uso: seed-demo-cycles <programId> [storeId]');
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
await datasource.initialize();
|
||||||
|
const repository = datasource.getRepository(RenewalCycle);
|
||||||
|
|
||||||
|
await repository.delete({ programId });
|
||||||
|
|
||||||
|
const random = makeRandom(20260807);
|
||||||
|
const cycles: RenewalCycle[] = [];
|
||||||
|
|
||||||
|
PROFILE.forEach(profile => {
|
||||||
|
for (let index = 0; index < profile.samples; index += 1) {
|
||||||
|
const emissionDays = Math.max(
|
||||||
|
1,
|
||||||
|
Math.round(
|
||||||
|
profile.medianDays + (random() * 2 - 1) * profile.spreadDays,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
// Protocolos espalhados pelos últimos ~18 meses
|
||||||
|
const protocolAt = new Date();
|
||||||
|
protocolAt.setDate(
|
||||||
|
protocolAt.getDate() - Math.round(60 + random() * 480) - emissionDays,
|
||||||
|
);
|
||||||
|
const issuedAt = new Date(protocolAt);
|
||||||
|
issuedAt.setDate(issuedAt.getDate() + emissionDays);
|
||||||
|
|
||||||
|
cycles.push(
|
||||||
|
repository.create({
|
||||||
|
programId,
|
||||||
|
storeId: storeId ?? programId,
|
||||||
|
typeCode: profile.typeCode,
|
||||||
|
issuingBody: profile.issuingBody,
|
||||||
|
protocolDate: dateKey(protocolAt),
|
||||||
|
issuedDate: dateKey(issuedAt),
|
||||||
|
emissionDays,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
await repository.save(cycles);
|
||||||
|
console.log(
|
||||||
|
`Seed ok: ${cycles.length} ciclos para o programa ${programId} (${PROFILE.length} órgãos/tipos).`,
|
||||||
|
);
|
||||||
|
await datasource.destroy();
|
||||||
|
}
|
||||||
|
|
||||||
|
main().catch(error => {
|
||||||
|
console.error(error);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user