F4.7 · servicio de reincidencias

This commit is contained in:
2026-09-07 20:10:38 -03:00
parent 41b52d34bf
commit b160e7344b
@@ -0,0 +1,301 @@
import {
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CreateRecurrentInspectionFindingDto } from './dto/create-recurrent-inspection-finding.dto';
@Injectable()
export class InspectionFindingRecurrenceService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async candidates(actId: string, assetId: string) {
await this.requireDraftActAsset(actId, assetId);
const data = await this.dataSource.query(`
SELECT
finding.id,
finding.code,
finding.title,
finding.description,
finding.severity,
finding.catalog_item_id AS "catalogItemId",
finding.is_recurrence AS "isRecurrence",
finding.antecedent_finding_id AS "antecedentFindingId",
finding.created_at AS "createdAt",
act.id AS "actId",
act.code AS "actCode",
act.occurred_at AS "actOccurredAt",
visit.id AS "visitId",
visit.code AS "visitCode"
FROM inspection_findings finding
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE finding.asset_id = $1
AND finding.status = 'OPEN'
AND finding.act_id <> $2
ORDER BY act.occurred_at DESC, finding.created_at DESC, finding.id DESC
`, [assetId, actId]);
return { data };
}
async create(
actId: string,
dto: CreateRecurrentInspectionFindingDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const [act] = await manager.query(`
SELECT
act.id,
act.code,
act.status,
act.occurred_at AS "occurredAt",
act.visit_id AS "visitId",
visit.status AS "visitStatus"
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
WHERE act.id = $1
FOR UPDATE OF act, visit
`, [actId]) as Array<{
id: string;
code: string;
status: string;
occurredAt: Date;
visitId: string;
visitStatus: string;
}>;
if (!act) throw this.actNotFound();
if (act.status !== 'DRAFT' || act.visitStatus !== 'IN_PROGRESS') {
throw new ConflictException({
code: 'INSPECTION_FINDING_ACT_NOT_EDITABLE',
message: 'La reincidencia sólo puede registrarse mientras el Acta está en borrador y la inspección en curso',
});
}
if (!principal.permissions.includes('inspections.manage')) {
const [member] = await manager.query(`
SELECT 1 AS found
FROM inspection_visit_members
WHERE visit_id = $1
AND user_id = $2
AND included = true
LIMIT 1
`, [act.visitId, principal.userId]) as Array<{ found: number }>;
if (!member) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La inspección no está asignada al usuario actual',
});
}
}
const [antecedent] = await manager.query(`
SELECT
finding.id,
finding.act_id AS "actId",
finding.asset_id AS "assetId",
finding.catalog_item_id AS "catalogItemId",
finding.title,
finding.legal_basis AS "legalBasis",
finding.glossary,
finding.catalog_revision AS "catalogRevision",
finding.suggested_severity AS "suggestedSeverity",
finding.severity,
finding.status,
previous_act.code AS "actCode",
previous_act.occurred_at AS "occurredAt"
FROM inspection_findings finding
INNER JOIN inspection_acts previous_act ON previous_act.id = finding.act_id
WHERE finding.id = $1
FOR SHARE OF finding, previous_act
`, [dto.antecedentFindingId]) as Array<{
id: string;
actId: string;
assetId: string;
catalogItemId: string | null;
title: string;
legalBasis: string | null;
glossary: string | null;
catalogRevision: number | null;
suggestedSeverity: number | null;
severity: number | null;
status: string;
actCode: string;
occurredAt: Date;
}>;
if (!antecedent) {
throw new NotFoundException({
code: 'INSPECTION_FINDING_ANTECEDENT_NOT_FOUND',
message: 'No se encontró el Hallazgo antecedente seleccionado',
});
}
if (antecedent.status !== 'OPEN') {
throw new ConflictException({
code: 'INSPECTION_FINDING_ANTECEDENT_RESOLVED',
message: 'Sólo un Hallazgo anterior todavía abierto puede originar una reincidencia',
});
}
if (antecedent.actId === actId) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RECURRENCE_SAME_ACT',
message: 'Una reincidencia debe referenciar un Hallazgo de un Acta anterior',
});
}
if (new Date(antecedent.occurredAt).getTime() > new Date(act.occurredAt).getTime()) {
throw new ConflictException({
code: 'INSPECTION_FINDING_ANTECEDENT_AFTER_ACT',
message: 'El antecedente no puede pertenecer a un Acta posterior a la actual',
});
}
const [assetLink] = await manager.query(`
SELECT 1 AS found
FROM inspection_act_assets
WHERE act_id = $1 AND asset_id = $2 AND included = true
LIMIT 1
`, [actId, antecedent.assetId]) as Array<{ found: number }>;
if (!assetLink) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RECURRENCE_ASSET_NOT_IN_ACT',
message: 'El Inventario del Hallazgo antecedente no forma parte del Acta actual',
});
}
const [sequence] = await manager.query(`
SELECT COALESCE(MAX(finding_number), 0)::integer + 1 AS number
FROM inspection_findings
WHERE act_id = $1
`, [actId]) as Array<{ number: number }>;
const findingNumber = Number(sequence?.number ?? 1);
if (findingNumber > 999) {
throw new ConflictException({
code: 'INSPECTION_FINDING_SEQUENCE_EXHAUSTED',
message: 'El Acta alcanzó el máximo de 999 Hallazgos',
});
}
const code = `${act.code}-H${String(findingNumber).padStart(3, '0')}`;
const [created] = await manager.query(`
INSERT INTO inspection_findings (
act_id,
asset_id,
catalog_item_id,
finding_number,
code,
status,
title,
description,
legal_basis,
glossary,
catalog_revision,
suggested_severity,
severity,
is_recurrence,
antecedent_finding_id,
correction_due_on,
current_version,
created_by,
updated_by
) VALUES (
$1,$2,$3,$4,$5,'OPEN',$6,$7,$8,$9,$10,$11,$12,true,$13,$14,1,$15,$15
)
RETURNING id, created_at AS "createdAt", updated_at AS "updatedAt"
`, [
actId,
antecedent.assetId,
antecedent.catalogItemId,
findingNumber,
code,
antecedent.title,
dto.description,
antecedent.legalBasis,
antecedent.glossary,
antecedent.catalogRevision,
antecedent.suggestedSeverity,
dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity,
antecedent.id,
dto.correctionDueOn ?? null,
principal.userId,
]) as Array<{ id: string; createdAt: Date; updatedAt: Date }>;
const snapshot = {
id: created.id,
actId,
assetId: antecedent.assetId,
catalogItemId: antecedent.catalogItemId,
findingNumber,
code,
status: 'OPEN',
title: antecedent.title,
description: dto.description,
legalBasis: antecedent.legalBasis,
glossary: antecedent.glossary,
catalogRevision: antecedent.catalogRevision,
suggestedSeverity: antecedent.suggestedSeverity,
severity: dto.severity ?? antecedent.severity ?? antecedent.suggestedSeverity,
isRecurrence: true,
antecedentFindingId: antecedent.id,
antecedentCode: `${antecedent.actCode}`,
correctionDueOn: dto.correctionDueOn ?? null,
currentVersion: 1,
createdAt: created.createdAt,
updatedAt: created.updatedAt,
};
await manager.query(`
INSERT INTO inspection_finding_versions (
finding_id, version_number, event, snapshot, actor_user_id, actor_username
) VALUES ($1, 1, 'CREATED', $2::jsonb, $3, $4)
`, [created.id, snapshot, principal.userId, principal.username]);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_CREATED,
entityType: 'inspection_finding',
entityId: created.id,
afterData: snapshot,
metadata: {
actId,
visitId: act.visitId,
recurrence: true,
antecedentFindingId: antecedent.id,
},
}, manager);
return snapshot;
});
}
private async requireDraftActAsset(actId: string, assetId: string): Promise<void> {
const [row] = await this.dataSource.query(`
SELECT 1 AS found
FROM inspection_acts act
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
INNER JOIN inspection_act_assets link
ON link.act_id = act.id AND link.asset_id = $2 AND link.included = true
WHERE act.id = $1
AND act.status = 'DRAFT'
AND visit.status = 'IN_PROGRESS'
LIMIT 1
`, [actId, assetId]) as Array<{ found: number }>;
if (!row) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RECURRENCE_CONTEXT_INVALID',
message: 'El Inventario debe estar incluido en un Acta en borrador de una inspección en curso',
});
}
}
private actNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
}