F4.7 · servicio de reincidencias
This commit is contained in:
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user