F4: implement finding recurrence workflow
This commit is contained in:
@@ -0,0 +1,232 @@
|
||||
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';
|
||||
|
||||
interface FindingRecurrenceContext {
|
||||
id: string;
|
||||
assetId: string;
|
||||
catalogItemId: string | null;
|
||||
title: string;
|
||||
createdAt: Date;
|
||||
actId: string;
|
||||
actStatus: string;
|
||||
visitId: string;
|
||||
visitStatus: string;
|
||||
}
|
||||
|
||||
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 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,
|
||||
) {
|
||||
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_RECURRENCE_LINKED,
|
||||
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 },
|
||||
}, manager);
|
||||
});
|
||||
return this.findings.getById(id);
|
||||
}
|
||||
|
||||
async clear(
|
||||
id: string,
|
||||
principal: AuthPrincipal,
|
||||
request: RequestWithContext,
|
||||
) {
|
||||
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_RECURRENCE_CLEARED,
|
||||
entityType: 'inspection_finding',
|
||||
entityId: id,
|
||||
beforeData: before,
|
||||
afterData: { isRecurrence: false, recurrenceOfFindingId: null },
|
||||
metadata: { actId: current.actId, visitId: current.visitId },
|
||||
}, manager);
|
||||
});
|
||||
return this.findings.getById(id);
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user