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>
190 lines
5.5 KiB
TypeScript
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,
|
|
),
|
|
};
|
|
});
|
|
}
|
|
}
|