feat: Onda 1 de IA — extração por LLM e prazo de renovação dinâmico (1.1.0) #1

Merged
breno.pires merged 3 commits from feat/ai-onda-1 into master 2026-08-07 05:56:01 +00:00
18 changed files with 859 additions and 21 deletions
Showing only changes of commit 56368d5517 - Show all commits

View File

@ -16,6 +16,19 @@ 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 — 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

View File

@ -2,6 +2,20 @@
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
- **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

View File

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

View 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;`);
}
}

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

@ -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,18 +111,29 @@ 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 }) => {
const lead = this.leadTimeService.effectiveLeadFor(type, leadIndex);
return {
typeCode: type.code, typeCode: type.code,
typeName: type.name, typeName: type.name,
criticality: type.criticality, criticality: type.criticality,
@ -128,7 +141,10 @@ export class ComplianceService {
expiryDate: document!.expiryDate!, expiryDate: document!.expiryDate!,
daysToExpiry: daysBetween(today, document!.expiryDate!), daysToExpiry: daysBetween(today, document!.expiryDate!),
renewalLeadDays: type.renewalLeadDays, 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);
} }

View File

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

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

View 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
* 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;
}
}

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

View File

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

View File

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

View 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 protocoloemissã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);
});