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