269 lines
10 KiB
TypeScript
269 lines
10 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { DataSource, EntityManager } 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 { SetFindingRecurrenceDto } from './dto/set-finding-recurrence.dto';
|
|
import { InspectionFindingsService } from './inspection-findings.service';
|
|
import type { InspectionFindingView } from './inspection-findings.service';
|
|
|
|
interface FindingRecurrenceContext {
|
|
id: string;
|
|
assetId: string;
|
|
catalogItemId: string | null;
|
|
title: string;
|
|
createdAt: Date;
|
|
actId: string;
|
|
actStatus: string;
|
|
visitId: string;
|
|
visitStatus: string;
|
|
}
|
|
|
|
export interface FindingRecurrenceState {
|
|
isRecurrence: boolean;
|
|
recurrenceOfFindingId: string | null;
|
|
recurrenceOfFindingCode: string | null;
|
|
}
|
|
|
|
export type FindingWithRecurrenceView = InspectionFindingView & FindingRecurrenceState;
|
|
|
|
function comparable(value: string): string {
|
|
return value
|
|
.normalize('NFD')
|
|
.replace(/[\u0300-\u036f]/g, '')
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9]+/g, ' ')
|
|
.trim()
|
|
.replace(/\s+/g, ' ');
|
|
}
|
|
|
|
@Injectable()
|
|
export class FindingRecurrenceService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly audit: AuditService,
|
|
private readonly findings: InspectionFindingsService,
|
|
) {}
|
|
|
|
async state(id: string): Promise<FindingRecurrenceState> {
|
|
const [row] = await this.dataSource.query(`
|
|
SELECT
|
|
finding.is_recurrence AS "isRecurrence",
|
|
finding.recurrence_of_finding_id AS "recurrenceOfFindingId",
|
|
antecedent.code AS "recurrenceOfFindingCode"
|
|
FROM inspection_findings finding
|
|
LEFT JOIN inspection_findings antecedent ON antecedent.id = finding.recurrence_of_finding_id
|
|
WHERE finding.id = $1
|
|
`, [id]) as FindingRecurrenceState[];
|
|
if (!row) {
|
|
throw new NotFoundException({
|
|
code: 'INSPECTION_FINDING_NOT_FOUND',
|
|
message: 'Hallazgo no encontrado',
|
|
});
|
|
}
|
|
return row;
|
|
}
|
|
|
|
async candidates(id: string) {
|
|
const current = await this.requireFinding(this.dataSource.manager, id, false);
|
|
const rows = await this.dataSource.query(`
|
|
SELECT
|
|
finding.id,finding.code,finding.title,finding.description,finding.status,
|
|
finding.catalog_item_id AS "catalogItemId",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",visit.status AS "visitStatus"
|
|
FROM inspection_findings finding
|
|
JOIN inspection_acts act ON act.id=finding.act_id
|
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
|
WHERE finding.asset_id=$1
|
|
AND finding.id<>$2
|
|
AND finding.status<>'VOIDED'
|
|
AND finding.created_at<$3
|
|
AND (
|
|
($4::uuid IS NOT NULL AND finding.catalog_item_id=$4::uuid)
|
|
OR ($4::uuid IS NULL AND lower(btrim(finding.title))=lower(btrim($5)))
|
|
)
|
|
ORDER BY finding.created_at DESC,finding.id DESC
|
|
LIMIT 20
|
|
`, [current.assetId, current.id, current.createdAt, current.catalogItemId, current.title]);
|
|
return {
|
|
findingId: current.id,
|
|
assetId: current.assetId,
|
|
hasCandidates: rows.length > 0,
|
|
data: rows,
|
|
};
|
|
}
|
|
|
|
async link(
|
|
id: string,
|
|
dto: SetFindingRecurrenceDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<FindingWithRecurrenceView> {
|
|
assertMobileInspector(principal);
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const current = await this.requireFinding(manager, id, true);
|
|
this.assertEditable(current);
|
|
await this.assertActorAssigned(manager, current.visitId, principal);
|
|
if (id === dto.recurrenceOfFindingId) {
|
|
throw new BadRequestException({
|
|
code: 'FINDING_CANNOT_RECUR_FROM_SELF',
|
|
message: 'Un Hallazgo no puede ser reincidencia de sí mismo',
|
|
});
|
|
}
|
|
const previous = await this.requireFinding(manager, dto.recurrenceOfFindingId, false);
|
|
if (previous.assetId !== current.assetId) {
|
|
throw new ConflictException({
|
|
code: 'FINDING_RECURRENCE_DIFFERENT_INVENTORY',
|
|
message: 'La reincidencia debe referir a un Hallazgo anterior del mismo Inventario',
|
|
});
|
|
}
|
|
if (new Date(previous.createdAt).getTime() >= new Date(current.createdAt).getTime()) {
|
|
throw new ConflictException({
|
|
code: 'FINDING_RECURRENCE_NOT_PREVIOUS',
|
|
message: 'El Hallazgo de referencia debe ser anterior al actual',
|
|
});
|
|
}
|
|
const sameCatalog = current.catalogItemId && previous.catalogItemId
|
|
? current.catalogItemId === previous.catalogItemId
|
|
: current.catalogItemId === previous.catalogItemId;
|
|
const sameTitle = comparable(current.title) === comparable(previous.title);
|
|
if (!sameCatalog && !sameTitle) {
|
|
throw new ConflictException({
|
|
code: 'FINDING_RECURRENCE_NOT_EQUIVALENT',
|
|
message: 'El antecedente seleccionado no corresponde al mismo tipo de Hallazgo',
|
|
});
|
|
}
|
|
|
|
const [before] = await manager.query(`
|
|
SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId"
|
|
FROM inspection_findings WHERE id=$1 FOR UPDATE
|
|
`, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>;
|
|
await manager.query(`
|
|
UPDATE inspection_findings
|
|
SET is_recurrence=true,recurrence_of_finding_id=$2,updated_by=$3,updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=$1
|
|
`, [id, previous.id, principal.userId]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_FINDING_UPDATED,
|
|
entityType: 'inspection_finding',
|
|
entityId: id,
|
|
beforeData: before ?? { isRecurrence: false, recurrenceOfFindingId: null },
|
|
afterData: {
|
|
isRecurrence: true,
|
|
recurrenceOfFindingId: previous.id,
|
|
recurrenceOfFindingCode: await this.codeForFinding(manager, previous.id),
|
|
},
|
|
metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'LINKED' },
|
|
}, manager);
|
|
});
|
|
const [finding, recurrence] = await Promise.all([
|
|
this.findings.getById(id),
|
|
this.state(id),
|
|
]);
|
|
return { ...finding, ...recurrence };
|
|
}
|
|
|
|
async clear(
|
|
id: string,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<FindingWithRecurrenceView> {
|
|
assertMobileInspector(principal);
|
|
await this.dataSource.transaction(async (manager) => {
|
|
const current = await this.requireFinding(manager, id, true);
|
|
this.assertEditable(current);
|
|
await this.assertActorAssigned(manager, current.visitId, principal);
|
|
const [before] = await manager.query(`
|
|
SELECT is_recurrence AS "isRecurrence",recurrence_of_finding_id AS "recurrenceOfFindingId"
|
|
FROM inspection_findings WHERE id=$1 FOR UPDATE
|
|
`, [id]) as Array<{ isRecurrence: boolean; recurrenceOfFindingId: string | null }>;
|
|
if (!before?.isRecurrence && !before?.recurrenceOfFindingId) return;
|
|
await manager.query(`
|
|
UPDATE inspection_findings
|
|
SET is_recurrence=false,recurrence_of_finding_id=NULL,updated_by=$2,updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=$1
|
|
`, [id, principal.userId]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_FINDING_UPDATED,
|
|
entityType: 'inspection_finding',
|
|
entityId: id,
|
|
beforeData: before,
|
|
afterData: { isRecurrence: false, recurrenceOfFindingId: null },
|
|
metadata: { actId: current.actId, visitId: current.visitId, recurrenceAction: 'CLEARED' },
|
|
}, manager);
|
|
});
|
|
const [finding, recurrence] = await Promise.all([
|
|
this.findings.getById(id),
|
|
this.state(id),
|
|
]);
|
|
return { ...finding, ...recurrence };
|
|
}
|
|
|
|
private async requireFinding(
|
|
manager: EntityManager,
|
|
id: string,
|
|
lock: boolean,
|
|
): Promise<FindingRecurrenceContext> {
|
|
const [row] = await manager.query(`
|
|
SELECT finding.id,finding.asset_id AS "assetId",finding.catalog_item_id AS "catalogItemId",
|
|
finding.title,finding.created_at AS "createdAt",finding.act_id AS "actId",
|
|
act.status AS "actStatus",visit.id AS "visitId",visit.status AS "visitStatus"
|
|
FROM inspection_findings finding
|
|
JOIN inspection_acts act ON act.id=finding.act_id
|
|
JOIN inspection_visits visit ON visit.id=act.visit_id
|
|
WHERE finding.id=$1
|
|
${lock ? 'FOR UPDATE OF finding,act,visit' : ''}
|
|
`, [id]) as FindingRecurrenceContext[];
|
|
if (!row) {
|
|
throw new NotFoundException({
|
|
code: 'INSPECTION_FINDING_NOT_FOUND',
|
|
message: 'Hallazgo no encontrado',
|
|
});
|
|
}
|
|
return row;
|
|
}
|
|
|
|
private assertEditable(finding: FindingRecurrenceContext): void {
|
|
if (finding.actStatus !== 'DRAFT' || finding.visitStatus !== 'IN_PROGRESS') {
|
|
throw new ConflictException({
|
|
code: 'FINDING_RECURRENCE_IMMUTABLE',
|
|
message: 'La reincidencia sólo puede definirse mientras el Acta está en BORRADOR y la Inspección en curso',
|
|
});
|
|
}
|
|
}
|
|
|
|
private async assertActorAssigned(
|
|
manager: EntityManager,
|
|
visitId: string,
|
|
principal: AuthPrincipal,
|
|
): Promise<void> {
|
|
if (principal.permissions.includes('inspections.manage')) return;
|
|
const [row] = await manager.query(`
|
|
SELECT 1 FROM inspection_visit_members
|
|
WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1
|
|
`, [visitId, principal.userId]) as unknown[];
|
|
if (!row) {
|
|
throw new ForbiddenException({
|
|
code: 'INSPECTION_NOT_ASSIGNED',
|
|
message: 'La Inspección no está asignada al usuario actual',
|
|
});
|
|
}
|
|
}
|
|
|
|
private async codeForFinding(manager: EntityManager, id: string): Promise<string | null> {
|
|
const [row] = await manager.query('SELECT code FROM inspection_findings WHERE id=$1', [id]) as Array<{ code: string }>;
|
|
return row?.code ?? null;
|
|
}
|
|
}
|