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