documents/src/modules/compliance/compliance.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

190 lines
5.5 KiB
TypeScript

import { Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { DocumentType } from '../catalog/documentType.entity';
import { CatalogService } from '../catalog/catalog.service';
import {
DocumentCriticality,
StoreDocumentStatus,
} from '../common/enum/document.enum';
import {
daysBetween,
resolveStatus,
todayKey,
} from '../common/utils/status.util';
import { StoreDocument } from '../document/storeDocument.entity';
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<DocumentCriticality, number> = {
[DocumentCriticality.Interdicao]: 3,
[DocumentCriticality.Multa]: 2,
[DocumentCriticality.Administrativa]: 1,
};
interface IObligation {
type: DocumentType;
status: StoreDocumentStatus;
document?: StoreDocument;
}
@Injectable()
export class ComplianceService {
constructor(
@InjectRepository(StoreDocument)
private readonly documentRepository: Repository<StoreDocument>,
private readonly catalogService: CatalogService,
private readonly leadTimeService: LeadTimeService,
) {}
/**
* Score ponderado por criticidade — a MESMA régua do painel
* (`mapping/Mapping.tsx#buildComplianceSummary`), agora com o backend como
* fonte da verdade: interdição pesa 3, multa 2, administrativa 1; "vence em
* breve" ainda vale meio peso e "em renovação" 0,75 (protocolado no prazo
* mantém o posto operante).
*/
async getSummary(params: {
programId: string;
storeId: string;
}): Promise<ComplianceSummaryResponseDto> {
const obligations = await this.getObligations(params);
const totals = {
valid: 0,
expiringSoon: 0,
inRenewal: 0,
expired: 0,
missing: 0,
};
let earnedWeight = 0;
let totalWeight = 0;
let criticalPending = 0;
obligations.forEach(({ type, status }) => {
const weight = CRITICALITY_WEIGHT[type.criticality] ?? 1;
totalWeight += weight;
switch (status) {
case StoreDocumentStatus.Valid:
totals.valid += 1;
earnedWeight += weight;
break;
case StoreDocumentStatus.ExpiringSoon:
totals.expiringSoon += 1;
earnedWeight += weight / 2;
break;
case StoreDocumentStatus.InRenewal:
totals.inRenewal += 1;
earnedWeight += weight * 0.75;
break;
case StoreDocumentStatus.Expired:
totals.expired += 1;
break;
case StoreDocumentStatus.Missing:
default:
totals.missing += 1;
break;
}
const isPending =
status === StoreDocumentStatus.Expired ||
status === StoreDocumentStatus.Missing;
if (isPending && type.criticality === DocumentCriticality.Interdicao) {
criticalPending += 1;
}
});
return {
storeId: params.storeId,
score: totalWeight
? Math.round((earnedWeight / totalWeight) * 100)
: 100,
totals,
criticalPending,
};
}
/**
* 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<UpcomingExpirationDto[]> {
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 }) => {
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);
}
/**
* Uma linha por obrigação do catálogo — obrigação sem documento entra como
* `missing`. É o cruzamento que o painel fazia no client, agora no dono do
* dado.
*/
async getObligations(params: {
programId: string;
storeId: string;
}): Promise<IObligation[]> {
const [types, documents] = await Promise.all([
this.catalogService.getActiveTypes(),
this.documentRepository.find({
where: { programId: params.programId, storeId: params.storeId },
}),
]);
const today = todayKey();
return types.map(type => {
const document = documents.find(item => item.typeCode === type.code);
if (!document) {
return { type, status: StoreDocumentStatus.Missing };
}
return {
type,
document,
status: resolveStatus(
{
expiryDate: document.expiryDate,
hasRenewalProtocol: !!document.renewalProtocolNumber,
renewalLeadDays: type.renewalLeadDays,
},
today,
),
};
});
}
}