diff --git a/AI-HISTORY.md b/AI-HISTORY.md index cfb7cc1..6c1a158 100644 --- a/AI-HISTORY.md +++ b/AI-HISTORY.md @@ -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) - **Tempo:** ~60 min diff --git a/CHANGELOG.md b/CHANGELOG.md index 57aaae3..54da8fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,20 @@ 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 ### Added diff --git a/package.json b/package.json index f77def7..f589225 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "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)", "author": "ClubPetro", "private": true, diff --git a/src/migrations/1786233600000-migration.ts b/src/migrations/1786233600000-migration.ts new file mode 100644 index 0000000..720bcc2 --- /dev/null +++ b/src/migrations/1786233600000-migration.ts @@ -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 { + 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 { + await queryRunner.query(`DROP TABLE IF EXISTS public.renewal_cycle;`); + } +} diff --git a/src/modules/alerts/__tests__/alerts.service.spec.ts b/src/modules/alerts/__tests__/alerts.service.spec.ts index a0f7143..283b52c 100644 --- a/src/modules/alerts/__tests__/alerts.service.spec.ts +++ b/src/modules/alerts/__tests__/alerts.service.spec.ts @@ -71,4 +71,33 @@ describe('AlertsService.computeDueAlerts — a régua', () => { await service.computeDueAlerts({ programId: PROGRAM_ID, storeId: STORE_ID }), ).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); + }); }); diff --git a/src/modules/alerts/alerts.service.ts b/src/modules/alerts/alerts.service.ts index c269b49..e1ef89a 100644 --- a/src/modules/alerts/alerts.service.ts +++ b/src/modules/alerts/alerts.service.ts @@ -11,6 +11,10 @@ export interface IDueAlert { trigger: 'lead' | 'd30' | 'd15' | 'd7' | 'd1' | 'expired'; daysToExpiry: number; 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 { - 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; return { @@ -56,6 +63,8 @@ export class AlertsService { trigger, daysToExpiry: item.daysToExpiry, expiryDate: item.expiryDate, + effectiveLeadDays: lead, + observed: item.observed ?? null, }; } diff --git a/src/modules/compliance/__tests__/compliance.service.spec.ts b/src/modules/compliance/__tests__/compliance.service.spec.ts index 98adc3c..52942e5 100644 --- a/src/modules/compliance/__tests__/compliance.service.spec.ts +++ b/src/modules/compliance/__tests__/compliance.service.spec.ts @@ -5,7 +5,9 @@ import { StoreDocumentStatus, } from '../../common/enum/document.enum'; import { todayKey } from '../../common/utils/status.util'; +import { IssuingBody } from '../../common/enum/document.enum'; import { ComplianceService } from '../compliance.service'; +import { LeadTimeService } from '../leadTime.service'; const PROGRAM_ID = '47031ca8-7586-40ba-acf6-f7af26d56a13'; const STORE_ID = '6b4f2dfa-2400-4b6e-a587-18978677ef20'; @@ -20,12 +22,14 @@ const type = (code: string, criticality: DocumentCriticality, lead = 30) => ({ code, name: code, criticality, + issuingBody: IssuingBody.OrgaoAmbiental, renewalLeadDays: lead, }); const setup = ({ types = [] as any[], documents = [] as any[], + cycles = [] as any[], } = {}) => { const documentRepository = repositoryMock({ find: jest.fn().mockResolvedValue(documents), @@ -33,9 +37,19 @@ const setup = ({ const catalogService = { 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( documentRepository as any, catalogService as any, + leadTimeService, ); return { service }; }; @@ -175,4 +189,43 @@ describe('ComplianceService.getUpcoming', () => { expect(upcoming[0].daysToExpiry).toBe(-3); 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 + }); }); diff --git a/src/modules/compliance/__tests__/leadTime.service.spec.ts b/src/modules/compliance/__tests__/leadTime.service.spec.ts new file mode 100644 index 0000000..871534e --- /dev/null +++ b/src/modules/compliance/__tests__/leadTime.service.spec.ts @@ -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 = {}, +): Record => ({ + 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, + }); + }); +}); diff --git a/src/modules/compliance/compliance.controller.ts b/src/modules/compliance/compliance.controller.ts index 528de12..c6642f9 100644 --- a/src/modules/compliance/compliance.controller.ts +++ b/src/modules/compliance/compliance.controller.ts @@ -12,13 +12,15 @@ import { import { ApiBearerAuth, ApiResponse, ApiTags } from '@nestjs/swagger'; 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 { ComplianceSummaryResponseDto, UpcomingExpirationDto, } from './dto/response/compliance.dto'; +import { LeadTimesResponseDto } from './dto/response/leadTime.dto'; import { GetUpcomingDto } from './dto/request/getUpcoming.dto'; +import { LeadTimeService } from './leadTime.service'; @ApiTags('Conformidade') @Controller('compliance') @@ -26,7 +28,10 @@ import { GetUpcomingDto } from './dto/request/getUpcoming.dto'; @UseInterceptors(FiltersInterceptor) @UseGuards(JwtAuthGuard) export class ComplianceController { - constructor(private readonly service: ComplianceService) {} + constructor( + private readonly service: ComplianceService, + private readonly leadTimeService: LeadTimeService, + ) {} @Get('summary') @ApiBearerAuth() @@ -45,4 +50,13 @@ export class ComplianceController { days: filters.days ?? 90, }); } + + @Get('lead-times') + @ApiBearerAuth() + @ApiResponse({ status: HttpStatus.OK, type: LeadTimesResponseDto }) + async getLeadTimes( + @Query() filters: TenantDto, + ): Promise { + return { data: await this.leadTimeService.getLeadTimes(filters.programId) }; + } } diff --git a/src/modules/compliance/compliance.module.ts b/src/modules/compliance/compliance.module.ts index edfb830..83bf3e9 100644 --- a/src/modules/compliance/compliance.module.ts +++ b/src/modules/compliance/compliance.module.ts @@ -6,16 +6,18 @@ import { CatalogModule } from '../catalog/catalog.module'; import { StoreDocument } from '../document/storeDocument.entity'; import { ComplianceController } from './compliance.controller'; import { ComplianceService } from './compliance.service'; +import { LeadTimeService } from './leadTime.service'; +import { RenewalCycle } from './renewalCycle.entity'; @Module({ imports: [ - TypeOrmModule.forFeature([StoreDocument]), + TypeOrmModule.forFeature([StoreDocument, RenewalCycle]), CatalogModule, // O AuthModule precisa ser importado em cada módulo que usa o JwtAuthGuard. AuthModule, ], - providers: [ComplianceService], + providers: [ComplianceService, LeadTimeService], controllers: [ComplianceController], - exports: [ComplianceService], + exports: [ComplianceService, LeadTimeService], }) export class ComplianceModule {} diff --git a/src/modules/compliance/compliance.service.ts b/src/modules/compliance/compliance.service.ts index bea77ac..1fee307 100644 --- a/src/modules/compliance/compliance.service.ts +++ b/src/modules/compliance/compliance.service.ts @@ -18,6 +18,7 @@ import { ComplianceSummaryResponseDto, UpcomingExpirationDto, } from './dto/response/compliance.dto'; +import { LeadTimeService } from './leadTime.service'; /** Peso da criticidade no score — o mesmo do painel. */ const CRITICALITY_WEIGHT: Record = { @@ -38,6 +39,7 @@ export class ComplianceService { @InjectRepository(StoreDocument) private readonly documentRepository: Repository, 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: { programId: string; storeId: string; days: number; }): Promise { - const obligations = await this.getObligations(params); + const [obligations, leadIndex] = await Promise.all([ + this.getObligations(params), + this.leadTimeService.getIndex(params.programId), + ]); const today = todayKey(); return obligations .filter(({ document }) => !!document?.expiryDate) - .map(({ type, status, document }) => ({ - typeCode: type.code, - typeName: type.name, - criticality: type.criticality, - status, - expiryDate: document!.expiryDate!, - daysToExpiry: daysBetween(today, document!.expiryDate!), - renewalLeadDays: type.renewalLeadDays, - })) + .map(({ type, status, document }) => { + const lead = this.leadTimeService.effectiveLeadFor(type, leadIndex); + return { + typeCode: type.code, + typeName: type.name, + criticality: type.criticality, + status, + expiryDate: document!.expiryDate!, + daysToExpiry: daysBetween(today, document!.expiryDate!), + renewalLeadDays: type.renewalLeadDays, + effectiveLeadDays: lead.effectiveLeadDays, + observed: lead.observed, + }; + }) .filter(item => item.daysToExpiry <= params.days) .sort((a, b) => a.daysToExpiry - b.daysToExpiry); } diff --git a/src/modules/compliance/dto/response/compliance.dto.ts b/src/modules/compliance/dto/response/compliance.dto.ts index 984411e..df13df2 100644 --- a/src/modules/compliance/dto/response/compliance.dto.ts +++ b/src/modules/compliance/dto/response/compliance.dto.ts @@ -1,9 +1,10 @@ -import { ApiProperty } from '@nestjs/swagger'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; import { DocumentCriticality, StoreDocumentStatus, } from '../../../common/enum/document.enum'; +import { ObservedLeadDto } from './leadTime.dto'; export class ComplianceTotalsDto { @ApiProperty() @@ -60,4 +61,17 @@ export class UpcomingExpirationDto { @ApiProperty() 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; } diff --git a/src/modules/compliance/dto/response/leadTime.dto.ts b/src/modules/compliance/dto/response/leadTime.dto.ts new file mode 100644 index 0000000..02fdc92 --- /dev/null +++ b/src/modules/compliance/dto/response/leadTime.dto.ts @@ -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; +} diff --git a/src/modules/compliance/leadTime.service.ts b/src/modules/compliance/leadTime.service.ts new file mode 100644 index 0000000..6ab5d12 --- /dev/null +++ b/src/modules/compliance/leadTime.service.ts @@ -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; + byBody: Map; +} + +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, + private readonly catalogService: CatalogService, + ) {} + + /** Uma ida ao banco; agregação em memória (amostras são pequenas). */ + async getIndex(programId: string): Promise { + const cycles = await this.cycleRepository.find({ where: { programId } }); + + const byTypeDays = new Map(); + const byBodyDays = new Map(); + + 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 { + 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; + } +} diff --git a/src/modules/compliance/renewalCycle.entity.ts b/src/modules/compliance/renewalCycle.entity.ts new file mode 100644 index 0000000..671ceed --- /dev/null +++ b/src/modules/compliance/renewalCycle.entity.ts @@ -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; +} diff --git a/src/modules/document/__tests__/document.service.spec.ts b/src/modules/document/__tests__/document.service.spec.ts index edf9a2d..726c893 100644 --- a/src/modules/document/__tests__/document.service.spec.ts +++ b/src/modules/document/__tests__/document.service.spec.ts @@ -23,11 +23,18 @@ const futureDate = (days: number): string => { const avcbType = { code: 'avcb', name: 'AVCB', + issuingBody: 'corpoDeBombeiros', renewalLeadDays: 60, keywords: ['avcb'], defaultValidityMonths: null, }; +/** Chamadas de save do manager para uma entidade (pelo nome da classe). */ +const savesOf = (manager: Record, entityName: string) => + (manager.save as jest.Mock).mock.calls.filter( + ([entity]: any[]) => entity?.name === entityName, + ); + interface ISetup { documents?: any[]; manager?: Record; @@ -190,6 +197,88 @@ describe('DocumentService.save', () => { 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 () => { const { service, catalogService, manager } = setup(); catalogService.getByCode = jest.fn().mockRejectedValue(new Error('TYPE_NOT_FOUND')); diff --git a/src/modules/document/document.service.ts b/src/modules/document/document.service.ts index f3b6e3c..bf62e7d 100644 --- a/src/modules/document/document.service.ts +++ b/src/modules/document/document.service.ts @@ -1,11 +1,16 @@ 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 { DataSource, Repository } from 'typeorm'; import { CatalogService } from '../catalog/catalog.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 { DOCUMENT_ANALYZER, 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() export class DocumentService { + private readonly logger = new Logger(DocumentService.name); + constructor( @InjectRepository(StoreDocument) private readonly documentRepository: Repository, @@ -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.expiryDate = input.expiryDate ?? null; document.documentNumber = @@ -124,6 +140,28 @@ export class DocumentService { 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({ programId: input.programId, storeId: input.storeId, diff --git a/src/scripts/seed-demo-cycles.ts b/src/scripts/seed-demo-cycles.ts new file mode 100644 index 0000000..c8870b7 --- /dev/null +++ b/src/scripts/seed-demo-cycles.ts @@ -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 [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 [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); +});