documents/src/modules/alerts/alerts.service.ts
Breno Pires 56368d5517 feat(compliance): prazo de renovacao dinamico pelo tempo observado por orgao
Cada renovacao que sai do orgao vira ciclo observado (protocolo -> emissao);
a mediana por orgao/tipo estica o prazo de inicio de renovacao quando o orgao
esta comprovadamente mais lento que o catalogo. O catalogo e piso legal: os
120 dias da LO nunca encolhem. Campos aditivos effectiveLeadDays/observed no
upcoming e nos alertas; GET /compliance/lead-times; seed deterministico.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-07 02:44:25 -03:00

85 lines
3.1 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { ComplianceService } from '../compliance/compliance.service';
import { UpcomingExpirationDto } from '../compliance/dto/response/compliance.dto';
import { StoreDocumentStatus } from '../common/enum/document.enum';
export interface IDueAlert {
typeCode: string;
typeName: string;
/** Qual degrau da régua disparou (lead | 30 | 15 | 7 | 1 | vencido). */
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;
}
/**
* Régua de alertas de vencimento.
*
* Degraus fixos (30/15/7/1/vencido) + o degrau específico do tipo
* (`renewalLeadDays` — na LO, 120 dias, porque protocolar no prazo prorroga a
* validade). Documento com renovação já protocolada NÃO alerta: a pendência
* do gestor acabou; acompanhar o órgão é outro fluxo (v3).
*
* v1.2 (TODO): envio de verdade — WhatsApp como canal principal (o dono de
* posto vive nele; reusar a infraestrutura de WhatsApp das campanhas),
* e-mail como secundário, escalonamento para o superior perto do vencimento
* e pendência rastreável que só fecha com documento novo anexado. Este
* service já entrega a lista pronta para o job diário consumir.
*/
@Injectable()
export class AlertsService {
constructor(private readonly complianceService: ComplianceService) {}
async computeDueAlerts(params: {
programId: string;
storeId: string;
}): Promise<IDueAlert[]> {
const upcoming = await this.complianceService.getUpcoming({
...params,
days: 365,
});
return upcoming
.filter(item => item.status !== StoreDocumentStatus.InRenewal)
.map(item => this.toAlert(item))
.filter((alert): alert is IDueAlert => alert !== null);
}
private toAlert(item: UpcomingExpirationDto): IDueAlert | null {
// 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 {
typeCode: item.typeCode,
typeName: item.typeName,
trigger,
daysToExpiry: item.daysToExpiry,
expiryDate: item.expiryDate,
effectiveLeadDays: lead,
observed: item.observed ?? null,
};
}
/** O degrau mais urgente que já foi atingido; nenhum → sem alerta hoje. */
private resolveTrigger(
daysToExpiry: number,
renewalLeadDays: number,
): IDueAlert['trigger'] | null {
if (daysToExpiry < 0) return 'expired';
if (daysToExpiry <= 1) return 'd1';
if (daysToExpiry <= 7) return 'd7';
if (daysToExpiry <= 15) return 'd15';
if (daysToExpiry <= 30) return 'd30';
if (daysToExpiry <= renewalLeadDays) return 'lead';
return null;
}
}