Files
dh-inspeccion-v2/api-v3/src/inspection-findings/inspection-findings.service.ts
T

1263 lines
52 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,
InspectionAct,
InspectionActStatus,
InspectionFinding,
InspectionFindingResponseDueBasis,
InspectionFindingStatus,
InspectionFindingVerificationEventType,
InspectionFindingVersionEvent,
InspectionVerificationOutcome,
InspectionVisit,
InspectionVisitStatus,
} from '../database/entities';
import type { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
import type { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
import type { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
import type { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto';
import type { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { appendVerificationEvent } from '../inspection-verifications/verification-event-ledger';
interface FindingContextView {
id: string;
code: string;
name: string;
}
interface FindingAssetView {
id: string;
code: string;
name: string;
commonName: string | null;
typeName: string;
operatorCompany: FindingContextView | null;
operationalArea: FindingContextView | null;
}
type FindingCatalogView = {
id: string;
code: string;
categoryId: string;
categoryName: string;
sourceNumber: number;
revision: number;
suggestedSeverity: number | null;
} | null;
interface FindingDocumentView {
actId: string;
actCode: string;
actStatus: InspectionActStatus;
visitId: string;
visitCode: string;
visitStatus: InspectionVisitStatus;
}
export interface InspectionFindingListItem {
id: string;
actId: string;
assetId: string;
catalogItemId: string | null;
findingNumber: number;
code: string;
status: InspectionFindingStatus;
title: string;
description: string;
legalBasis: string | null;
glossary: string | null;
catalogRevision: number | null;
suggestedSeverity: number | null;
severity: number | null;
correctionDueOn: string | null;
responseDueBasis: InspectionFindingResponseDueBasis | null;
responseDueDays: number | null;
responseDueBaseOn: string | null;
reportNotifiedOn: string | null;
companyResponse: string | null;
companyResponseReceivedOn: string | null;
companyCommittedCorrectionOn: string | null;
nextControlOn: string | null;
currentVersion: number;
closedAt: Date | null;
closureNotes: string | null;
asset: FindingAssetView;
catalog: FindingCatalogView;
document: FindingDocumentView;
verificationVisit: {
id: string;
code: string;
status: InspectionVisitStatus;
plannedStartAt: Date | null;
} | null;
latestVerification: {
visitId: string;
visitCode: string;
visitStatus: InspectionVisitStatus;
targetControlOn: string | null;
outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null;
resultNotes: string | null;
verifiedAt: Date | null;
resultRecordedAt: Date | null;
rescheduledControlOn: string | null;
evidenceCount: number;
} | null;
createdAt: Date;
updatedAt: Date;
}
interface FindingVersionView {
id: string;
versionNumber: number;
event: InspectionFindingVersionEvent;
snapshot: Record<string, unknown>;
actorUserId: string | null;
actorUsername: string | null;
createdAt: Date;
}
export interface FindingVerificationHistoryEvent {
id: string;
eventType: InspectionFindingVerificationEventType;
occurredAt: Date;
targetControlOn: string | null;
previousControlOn: string | null;
nextControlOn: string | null;
outcome: InspectionVerificationOutcome | null;
notes: string | null;
actorUserId: string | null;
actorUsername: string | null;
verificationVisit: {
id: string;
code: string;
status: InspectionVisitStatus;
plannedStartAt: Date | null;
} | null;
evidenceCount: number;
}
export type InspectionFindingOfficeStage =
| 'WAITING_COMPANY'
| 'COMPANY_OVERDUE'
| 'DEFINE_VERIFICATION'
| 'PLAN_VERIFICATION'
| 'VERIFICATION_PLANNED'
| 'RESCHEDULE_VERIFICATION'
| 'READY_TO_CLOSE'
| 'CLOSED';
export interface InspectionFindingOfficeWorkflow {
stage: InspectionFindingOfficeStage;
label: string;
action: string;
dueOn: string | null;
overdue: boolean;
}
export interface InspectionFindingView extends InspectionFindingListItem {
versions: FindingVersionView[];
verificationHistory: FindingVerificationHistoryEvent[];
officeWorkflow: InspectionFindingOfficeWorkflow;
}
export interface InspectionFindingWorkflowCounters {
open: number;
waitingCompany: number;
companyOverdue: number;
companyDueNext7Days: number;
awaitingVerificationSchedule: number;
toVerify: number;
verificationOverdue: number;
verificationNext30Days: number;
readyToClose: number;
closed: number;
}
export interface InspectionFindingGlobalPage {
data: InspectionFindingListItem[];
meta: { page: number; pageSize: number; total: number; totalPages: number };
counters: InspectionFindingWorkflowCounters;
}
interface CatalogSnapshot {
id: string;
title: string;
legalBasis: string | null;
glossary: string | null;
revision: number;
suggestedSeverity: number | null;
}
function findingNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_FINDING_NOT_FOUND',
message: 'Hallazgo de inspección no encontrado',
});
}
function actNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_ACT_NOT_FOUND',
message: 'Acta de inspección no encontrada',
});
}
function addCalendarDays(dateOnly: string, days: number): string {
const [year, month, day] = dateOnly.split('-').map(Number);
const date = new Date(Date.UTC(year, month - 1, day));
date.setUTCDate(date.getUTCDate() + days);
return date.toISOString().slice(0, 10);
}
@Injectable()
export class InspectionFindingsService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listForAct(actId: string): Promise<{ data: InspectionFindingListItem[] }> {
await this.requireAct(this.dataSource.manager, actId);
const data = await this.dataSource.query(
`${this.findingSelect('WHERE finding.act_id = $1')}
ORDER BY finding.finding_number, finding.id`,
[actId],
) as InspectionFindingListItem[];
return { data };
}
async listGlobal(query: ListInspectionFindingsQueryDto): Promise<InspectionFindingGlobalPage> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 25;
const workflow = query.workflow ?? 'OPEN';
const values: unknown[] = [];
const filters: string[] = [];
const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`;
const latestVerificationOutcome = `(SELECT latest_verification.outcome
FROM inspection_finding_verification_visits latest_verification
WHERE latest_verification.finding_id = finding.id
AND latest_verification.outcome IS NOT NULL
ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC
LIMIT 1)`;
const latestVerificationVisitStatus = `(SELECT latest_visit.status
FROM inspection_finding_verification_visits latest_verification
INNER JOIN inspection_visits latest_visit ON latest_visit.id = latest_verification.visit_id
WHERE latest_verification.finding_id = finding.id
AND latest_verification.outcome IS NOT NULL
ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC
LIMIT 1)`;
if (query.search) {
values.push(`%${query.search}%`);
const parameter = `$${values.length}`;
filters.push(`(
finding.code ILIKE ${parameter}
OR finding.title ILIKE ${parameter}
OR asset.code ILIKE ${parameter}
OR asset.name ILIKE ${parameter}
OR company.name ILIKE ${parameter}
OR area.name ILIKE ${parameter}
OR act.code ILIKE ${parameter}
)`);
}
if (query.companyId) {
values.push(query.companyId);
filters.push(`company.id = $${values.length}::uuid`);
}
if (query.areaId) {
values.push(query.areaId);
filters.push(`area.id = $${values.length}::uuid`);
}
if (query.inspectorId) {
values.push(query.inspectorId);
const inspector = `$${values.length}`;
filters.push(`(
visit.lead_inspector_user_id = ${inspector}::uuid
OR EXISTS (
SELECT 1 FROM inspection_visit_members member_filter
WHERE member_filter.visit_id = visit.id
AND member_filter.included = true
AND member_filter.user_id = ${inspector}::uuid
)
)`);
}
if (query.dateFrom) {
values.push(query.dateFrom);
filters.push(`act.occurred_at::date >= $${values.length}::date`);
}
if (query.dateTo) {
values.push(query.dateTo);
filters.push(`act.occurred_at::date <= $${values.length}::date`);
}
if (workflow === 'OPEN') filters.push(`finding.status = 'OPEN'`);
if (workflow === 'WAITING_COMPANY') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NULL`);
if (workflow === 'COMPANY_OVERDUE') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NULL AND finding.correction_due_on IS NOT NULL AND finding.correction_due_on < ${today}`);
if (workflow === 'TO_SCHEDULE_VERIFICATION') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NULL AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`);
if (workflow === 'TO_VERIFY') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NOT NULL AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`);
if (workflow === 'VERIFICATION_OVERDUE') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NOT NULL AND finding.next_control_on < ${today} AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`);
if (workflow === 'READY_TO_CLOSE') filters.push(`finding.status = 'OPEN' AND ${latestVerificationOutcome} = 'RESOLVED' AND ${latestVerificationVisitStatus} = 'CLOSED'`);
if (workflow === 'CLOSED') filters.push(`finding.status = 'CLOSED'`);
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
const countRows = await this.dataSource.query(`
SELECT COUNT(*)::integer AS total
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
INNER JOIN assets asset ON asset.id = finding.asset_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
${where}
`, values) as Array<{ total: number }>;
const total = Number(countRows[0]?.total ?? 0);
values.push(pageSize);
const limitParameter = `$${values.length}`;
values.push((page - 1) * pageSize);
const offsetParameter = `$${values.length}`;
const data = await this.dataSource.query(
`${this.findingSelect(where)}
ORDER BY
CASE WHEN finding.status = 'OPEN' THEN 0 ELSE 1 END,
COALESCE(finding.next_control_on, finding.correction_due_on, '9999-12-31'::date),
finding.updated_at DESC,
finding.code
LIMIT ${limitParameter} OFFSET ${offsetParameter}`,
values,
) as InspectionFindingListItem[];
const [counters] = await this.dataSource.query(`
SELECT
COUNT(*) FILTER (WHERE status = 'OPEN')::integer AS "open",
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL)::integer AS "waitingCompany",
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL AND correction_due_on IS NOT NULL AND correction_due_on < ${today})::integer AS "companyOverdue",
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL AND correction_due_on BETWEEN ${today} AND ${today} + 7)::integer AS "companyDueNext7Days",
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NULL AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "awaitingVerificationSchedule",
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NOT NULL AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "toVerify",
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NOT NULL AND next_control_on < ${today} AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "verificationOverdue",
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on BETWEEN ${today} AND ${today} + 30 AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "verificationNext30Days",
COUNT(*) FILTER (WHERE status = 'OPEN' AND (SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1) = 'RESOLVED' AND (SELECT vv.status FROM inspection_finding_verification_visits v INNER JOIN inspection_visits vv ON vv.id = v.visit_id WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1) = 'CLOSED')::integer AS "readyToClose",
COUNT(*) FILTER (WHERE status = 'CLOSED')::integer AS "closed"
FROM inspection_findings
`) as InspectionFindingWorkflowCounters[];
return {
data,
meta: {
page,
pageSize,
total,
totalPages: Math.ceil(total / pageSize),
},
counters: {
open: Number(counters?.open ?? 0),
waitingCompany: Number(counters?.waitingCompany ?? 0),
companyOverdue: Number(counters?.companyOverdue ?? 0),
companyDueNext7Days: Number(counters?.companyDueNext7Days ?? 0),
awaitingVerificationSchedule: Number(counters?.awaitingVerificationSchedule ?? 0),
toVerify: Number(counters?.toVerify ?? 0),
verificationOverdue: Number(counters?.verificationOverdue ?? 0),
verificationNext30Days: Number(counters?.verificationNext30Days ?? 0),
readyToClose: Number(counters?.readyToClose ?? 0),
closed: Number(counters?.closed ?? 0),
},
};
}
async getById(id: string): Promise<InspectionFindingView> {
return this.dataSource.transaction((manager) => this.loadView(manager, id));
}
async getVerificationHistory(id: string): Promise<FindingVerificationHistoryEvent[]> {
const [exists] = await this.dataSource.query('SELECT id FROM inspection_findings WHERE id = $1', [id]) as Array<{ id: string }>;
if (!exists) throw findingNotFound();
return this.loadVerificationHistory(this.dataSource.manager, id);
}
async create(
actId: string,
dto: CreateInspectionFindingDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionFindingView> {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const act = await this.lockAct(manager, actId);
const visit = await this.lockVisit(manager, act.visitId);
this.assertActEditable(act, visit);
await this.assertActorAssigned(manager, visit.id, principal);
await this.assertActAsset(manager, actId, dto.assetId);
const catalog = dto.catalogItemId
? await this.requireCatalogItem(manager, dto.catalogItemId, dto.assetId)
: null;
if (!catalog && !dto.customTitle?.trim()) {
throw new BadRequestException({
code: 'INSPECTION_FINDING_TITLE_REQUIRED',
message: 'Un hallazgo personalizado debe indicar un título',
});
}
const findingNumber = await this.allocateFindingNumber(manager, actId);
if (findingNumber > 999) {
throw new ConflictException({
code: 'INSPECTION_FINDING_SEQUENCE_EXHAUSTED',
message: 'El acta alcanzó el máximo de 999 hallazgos',
});
}
const finding = manager.getRepository(InspectionFinding).create({
actId,
assetId: dto.assetId,
catalogItemId: catalog?.id ?? null,
findingNumber,
code: `${act.code}-H${String(findingNumber).padStart(3, '0')}`,
status: InspectionFindingStatus.OPEN,
title: catalog?.title ?? dto.customTitle!.trim(),
description: dto.description,
legalBasis: catalog?.legalBasis ?? dto.customLegalBasis ?? null,
glossary: catalog?.glossary ?? null,
catalogRevision: catalog?.revision ?? null,
suggestedSeverity: catalog?.suggestedSeverity ?? null,
severity: dto.severity ?? catalog?.suggestedSeverity ?? null,
correctionDueOn: dto.correctionDueOn ?? null,
companyResponse: null,
companyResponseReceivedOn: null,
companyCommittedCorrectionOn: null,
nextControlOn: null,
currentVersion: 0,
closedAt: null,
closedBy: null,
closureNotes: null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(InspectionFinding).save(finding);
if (!catalog) {
await manager.query(`
INSERT INTO finding_catalog_proposals (
finding_id, asset_id, asset_type_id, proposed_title, proposed_legal_basis,
proposed_severity, description, status, created_by
)
SELECT $1, asset.id, asset.asset_type_id, $2, $3, $4, $5, 'PENDING', $6
FROM assets asset
WHERE asset.id = $7
ON CONFLICT (finding_id) DO NOTHING
`, [
finding.id,
finding.title,
finding.legalBasis,
finding.severity,
finding.description,
principal.userId,
finding.assetId,
]);
}
const versionNumber = await this.captureVersion(
manager,
finding,
InspectionFindingVersionEvent.CREATED,
principal,
);
const created = await this.loadView(manager, finding.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_CREATED,
entityType: 'inspection_finding',
entityId: finding.id,
afterData: this.auditView(created),
metadata: { actId, visitId: visit.id, versionNumber },
}, manager);
return created;
});
}
async update(
id: string,
dto: UpdateInspectionFindingDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionFindingView> {
assertMobileInspector(principal);
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const finding = await this.lockFinding(manager, id);
const act = await this.lockAct(manager, finding.actId);
const visit = await this.lockVisit(manager, act.visitId);
this.assertActEditable(act, visit);
this.assertFindingOpen(finding);
await this.assertActorAssigned(manager, visit.id, principal);
if (dto.assetId !== undefined) {
await this.assertActAsset(manager, act.id, dto.assetId);
if (finding.catalogItemId) {
await this.requireCatalogItem(manager, finding.catalogItemId, dto.assetId);
}
}
const before = await this.loadView(manager, id);
if (dto.assetId !== undefined) {
finding.assetId = dto.assetId;
if (!finding.catalogItemId) {
await manager.query(`
UPDATE finding_catalog_proposals proposal SET
asset_id = asset.id,
asset_type_id = asset.asset_type_id,
updated_at = CURRENT_TIMESTAMP
FROM assets asset
WHERE proposal.finding_id = $1
AND proposal.status = 'PENDING'
AND asset.id = $2
`, [finding.id, dto.assetId]);
}
}
if (dto.description !== undefined) finding.description = dto.description;
if (dto.severity !== undefined) finding.severity = dto.severity;
if (dto.correctionDueOn !== undefined) finding.correctionDueOn = dto.correctionDueOn;
finding.updatedBy = principal.userId;
await manager.getRepository(InspectionFinding).save(finding);
const versionNumber = await this.captureVersion(
manager,
finding,
InspectionFindingVersionEvent.UPDATED,
principal,
);
const updated = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_UPDATED,
entityType: 'inspection_finding',
entityId: id,
beforeData: this.auditView(before),
afterData: this.auditView(updated),
metadata: { actId: act.id, visitId: visit.id, versionNumber },
}, manager);
return updated;
});
}
async close(
id: string,
dto: CloseInspectionFindingDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionFindingView> {
return this.dataSource.transaction(async (manager) => {
const finding = await this.lockFinding(manager, id);
this.assertFindingOpen(finding);
const [activeVerification] = await manager.query(`
SELECT visit.code, visit.status
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE verification_link.finding_id = $1
AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')
ORDER BY verification_link.created_at DESC
LIMIT 1
`, [id]) as Array<{ code: string; status: InspectionVisitStatus }>;
if (activeVerification) {
throw new ConflictException({
code: 'INSPECTION_FINDING_VERIFICATION_ACTIVE',
message: `El hallazgo tiene una verificación activa (${activeVerification.code}) y no puede cerrarse todavía`,
});
}
const before = await this.loadView(manager, id);
finding.status = InspectionFindingStatus.CLOSED;
finding.closedAt = new Date();
finding.closedBy = principal.userId;
finding.closureNotes = dto.closureNotes;
finding.updatedBy = principal.userId;
await manager.getRepository(InspectionFinding).save(finding);
const versionNumber = await this.captureVersion(
manager,
finding,
InspectionFindingVersionEvent.FOLLOW_UP_UPDATED,
principal,
);
const updated = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_CLOSED,
entityType: 'inspection_finding',
entityId: id,
beforeData: this.auditView(before),
afterData: this.auditView(updated),
metadata: {
actId: updated.document.actId,
visitId: updated.document.visitId,
versionNumber,
},
}, manager);
return updated;
});
}
async updateFollowUp(
id: string,
dto: UpdateInspectionFindingFollowUpDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionFindingView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
return this.dataSource.transaction(async (manager) => {
const finding = await this.lockFinding(manager, id);
this.assertFindingOpen(finding);
const before = await this.loadView(manager, id);
if (finding.companyResponseReceivedOn && (dto.companyResponse !== undefined || dto.companyResponseReceivedOn !== undefined)) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RESPONSE_IMMUTABLE',
message: 'La primera respuesta registrada no se modifica. Las nuevas presentaciones deben agregarse a la trazabilidad.',
});
}
const deadlineTouched = dto.responseDueBasis !== undefined
|| dto.responseDueDays !== undefined
|| dto.reportNotifiedOn !== undefined;
if (deadlineTouched && finding.companyResponseReceivedOn) {
throw new ConflictException({
code: 'INSPECTION_FINDING_RESPONSE_DEADLINE_LOCKED',
message: 'El plazo administrativo ya no puede modificarse después de registrar la respuesta de la empresa',
});
}
if (deadlineTouched) {
const basis = dto.responseDueBasis !== undefined ? dto.responseDueBasis : finding.responseDueBasis;
const days = dto.responseDueDays !== undefined ? dto.responseDueDays : finding.responseDueDays;
const reportNotifiedOn = dto.reportNotifiedOn !== undefined ? dto.reportNotifiedOn : finding.reportNotifiedOn;
if (!basis || days === null || days === undefined) {
throw new BadRequestException({
code: 'INSPECTION_FINDING_RESPONSE_DEADLINE_INCOMPLETE',
message: 'Definí el origen del plazo y la cantidad de días para calcular el vencimiento de respuesta',
});
}
let baseOn: string;
if (basis === InspectionFindingResponseDueBasis.FINDING_DATE) {
const [row] = await manager.query(`
SELECT act.occurred_at::date::text AS "findingDate"
FROM inspection_acts act
WHERE act.id = $1
`, [finding.actId]) as Array<{ findingDate: string }>;
if (!row?.findingDate) throw actNotFound();
baseOn = row.findingDate;
} else {
if (!reportNotifiedOn) {
throw new BadRequestException({
code: 'INSPECTION_FINDING_REPORT_NOTIFICATION_REQUIRED',
message: 'Indicá la fecha en que el informe fue notificado para iniciar el plazo de respuesta',
});
}
baseOn = reportNotifiedOn;
}
finding.responseDueBasis = basis;
finding.responseDueDays = days;
finding.reportNotifiedOn = reportNotifiedOn ?? null;
finding.responseDueBaseOn = baseOn;
finding.correctionDueOn = addCalendarDays(baseOn, days);
}
const response = dto.companyResponse !== undefined
? dto.companyResponse
: finding.companyResponse;
const receivedOn = dto.companyResponseReceivedOn !== undefined
? dto.companyResponseReceivedOn
: finding.companyResponseReceivedOn;
const committedOn = dto.companyCommittedCorrectionOn !== undefined
? dto.companyCommittedCorrectionOn
: finding.companyCommittedCorrectionOn;
if ((response === null) !== (receivedOn === null)) {
throw new BadRequestException({
code: 'INSPECTION_FINDING_RESPONSE_INCOMPLETE',
message: 'La respuesta de la empresa debe incluir texto y fecha de recepción',
});
}
if (committedOn && !receivedOn) {
throw new BadRequestException({
code: 'INSPECTION_FINDING_COMMITMENT_WITHOUT_RESPONSE',
message: 'La fecha prometida requiere registrar primero la respuesta de la empresa',
});
}
if (committedOn && receivedOn && committedOn < receivedOn) {
throw new BadRequestException({
code: 'INSPECTION_FINDING_COMMITMENT_BEFORE_RESPONSE',
message: 'La fecha prometida no puede ser anterior a la respuesta recibida',
});
}
if (dto.companyResponse !== undefined) finding.companyResponse = dto.companyResponse;
if (dto.companyResponseReceivedOn !== undefined) {
finding.companyResponseReceivedOn = dto.companyResponseReceivedOn;
}
if (dto.companyCommittedCorrectionOn !== undefined) {
finding.companyCommittedCorrectionOn = dto.companyCommittedCorrectionOn;
}
if (dto.nextControlOn !== undefined) finding.nextControlOn = dto.nextControlOn;
finding.updatedBy = principal.userId;
await manager.getRepository(InspectionFinding).save(finding);
if (dto.nextControlOn !== undefined && dto.nextControlOn !== before.nextControlOn) {
const eventType = dto.nextControlOn === null
? InspectionFindingVerificationEventType.CONTROL_DATE_CLEARED
: before.nextControlOn === null
? InspectionFindingVerificationEventType.CONTROL_DATE_DEFINED
: InspectionFindingVerificationEventType.CONTROL_DATE_CHANGED;
await appendVerificationEvent(manager, {
findingId: id,
eventType,
occurredAt: new Date(),
targetControlOn: dto.nextControlOn,
previousControlOn: before.nextControlOn,
nextControlOn: dto.nextControlOn,
notes: dto.nextControlOn === null
? 'Fecha de verificación retirada desde seguimiento de oficina'
: before.nextControlOn === null
? 'Fecha de verificación definida desde seguimiento de oficina'
: 'Fecha de verificación reprogramada desde seguimiento de oficina',
}, principal);
}
const versionNumber = await this.captureVersion(
manager,
finding,
InspectionFindingVersionEvent.FOLLOW_UP_UPDATED,
principal,
);
const updated = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_FINDING_FOLLOW_UP_UPDATED,
entityType: 'inspection_finding',
entityId: id,
beforeData: this.auditView(before),
afterData: this.auditView(updated),
metadata: {
actId: updated.document.actId,
visitId: updated.document.visitId,
versionNumber,
remainsOpen: updated.status === InspectionFindingStatus.OPEN,
},
}, manager);
return updated;
});
}
private findingSelect(where: string): string {
return `
SELECT
finding.id,
finding.act_id AS "actId",
finding.asset_id AS "assetId",
finding.catalog_item_id AS "catalogItemId",
finding.finding_number AS "findingNumber",
finding.code,
finding.status,
finding.title,
finding.description,
finding.legal_basis AS "legalBasis",
finding.glossary,
finding.catalog_revision AS "catalogRevision",
finding.suggested_severity AS "suggestedSeverity",
finding.severity,
finding.correction_due_on AS "correctionDueOn",
finding.response_due_basis AS "responseDueBasis",
finding.response_due_days AS "responseDueDays",
finding.response_due_base_on AS "responseDueBaseOn",
finding.report_notified_on AS "reportNotifiedOn",
finding.company_response AS "companyResponse",
finding.company_response_received_on AS "companyResponseReceivedOn",
finding.company_committed_correction_on AS "companyCommittedCorrectionOn",
finding.next_control_on AS "nextControlOn",
finding.current_version AS "currentVersion",
finding.closed_at AS "closedAt",
finding.closure_notes AS "closureNotes",
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'commonName', asset.common_name,
'typeName', asset_type.name,
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', company.id, 'code', company.code, 'name', company.name
) END,
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', area.id, 'code', area.code, 'name', area.name
) END
) AS asset,
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', catalog.id,
'code', catalog.code,
'categoryId', category.id,
'categoryName', category.name,
'sourceNumber', catalog.source_number,
'revision', finding.catalog_revision,
'suggestedSeverity', finding.suggested_severity
) END AS catalog,
JSONB_BUILD_OBJECT(
'actId', act.id,
'actCode', act.code,
'actStatus', act.status,
'visitId', visit.id,
'visitCode', visit.code,
'visitStatus', visit.status
) AS document,
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', verification_visit.id,
'code', verification_visit.code,
'status', verification_visit.status,
'plannedStartAt', verification_visit.planned_start_at
) END AS "verificationVisit",
CASE WHEN latest_verification.visit_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'visitId', latest_verification.visit_id,
'visitCode', latest_verification.visit_code,
'visitStatus', latest_verification.visit_status,
'targetControlOn', latest_verification.target_control_on,
'outcome', latest_verification.outcome,
'resultNotes', latest_verification.result_notes,
'verifiedAt', latest_verification.verified_at,
'resultRecordedAt', latest_verification.result_recorded_at,
'rescheduledControlOn', latest_verification.rescheduled_control_on,
'evidenceCount', latest_verification.evidence_count
) END AS "latestVerification",
finding.created_at AS "createdAt",
finding.updated_at AS "updatedAt"
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
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets company ON company.id = asset.operator_company_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
LEFT JOIN finding_categories category ON category.id = catalog.category_id
LEFT JOIN LATERAL (
SELECT verification_target.id, verification_target.code,
verification_target.status, verification_target.planned_start_at
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
WHERE verification_link.finding_id = finding.id
AND verification_target.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')
ORDER BY verification_link.created_at DESC, verification_link.id DESC
LIMIT 1
) verification_visit ON true
LEFT JOIN LATERAL (
SELECT
verification_link.visit_id,
verification_target.code AS visit_code,
verification_target.status AS visit_status,
verification_link.target_control_on,
verification_link.outcome,
verification_link.result_notes,
verification_link.verified_at,
verification_link.result_recorded_at,
verification_link.rescheduled_control_on,
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence evidence
WHERE evidence.finding_id = finding.id
AND evidence.verification_visit_id = verification_link.visit_id
AND evidence.purpose = 'VERIFICATION') AS evidence_count
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
WHERE verification_link.finding_id = finding.id
ORDER BY COALESCE(verification_link.result_recorded_at, verification_link.created_at) DESC,
verification_link.id DESC
LIMIT 1
) latest_verification ON true
${where}
`;
}
private async loadView(manager: EntityManager, id: string): Promise<InspectionFindingView> {
const [finding] = await manager.query(
this.findingSelect('WHERE finding.id = $1'),
[id],
) as InspectionFindingListItem[];
if (!finding) throw findingNotFound();
const versions = await manager.query(`
SELECT
id,
version_number AS "versionNumber",
event,
snapshot,
actor_user_id AS "actorUserId",
actor_username AS "actorUsername",
created_at AS "createdAt"
FROM inspection_finding_versions
WHERE finding_id = $1
ORDER BY version_number DESC
`, [id]) as FindingVersionView[];
const verificationHistory = await this.loadVerificationHistory(manager, id);
return { ...finding, versions, verificationHistory, officeWorkflow: this.officeWorkflow(finding) };
}
private async loadVerificationHistory(
manager: EntityManager,
findingId: string,
): Promise<FindingVerificationHistoryEvent[]> {
return manager.query(`
SELECT
event.id,
event.event_type AS "eventType",
event.occurred_at AS "occurredAt",
event.target_control_on AS "targetControlOn",
event.previous_control_on AS "previousControlOn",
event.next_control_on AS "nextControlOn",
event.outcome,
event.notes,
event.actor_user_id AS "actorUserId",
COALESCE(event.actor_username, actor.username) AS "actorUsername",
CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', visit.id,
'code', visit.code,
'status', visit.status,
'plannedStartAt', visit.planned_start_at
) END AS "verificationVisit",
CASE WHEN visit.id IS NULL THEN 0 ELSE (
SELECT COUNT(*)::integer
FROM inspection_finding_evidence evidence
WHERE evidence.finding_id = event.finding_id
AND evidence.verification_visit_id = visit.id
AND evidence.purpose = 'VERIFICATION'
) END AS "evidenceCount"
FROM inspection_finding_verification_events event
LEFT JOIN inspection_visits visit ON visit.id = event.verification_visit_id
LEFT JOIN users actor ON actor.id = event.actor_user_id
WHERE event.finding_id = $1
ORDER BY event.occurred_at DESC, event.created_at DESC, event.id DESC
`, [findingId]) as Promise<FindingVerificationHistoryEvent[]>;
}
private officeWorkflow(finding: InspectionFindingListItem): InspectionFindingOfficeWorkflow {
if (finding.status === InspectionFindingStatus.CLOSED) {
return {
stage: 'CLOSED',
label: 'Hallazgo cerrado',
action: 'El seguimiento está finalizado y permanece disponible en el expediente histórico.',
dueOn: null,
overdue: false,
};
}
const today = this.mendozaDate();
const latest = finding.latestVerification;
if (latest?.outcome === 'RESOLVED' && latest.visitStatus === InspectionVisitStatus.CLOSED) {
return {
stage: 'READY_TO_CLOSE',
label: 'Listo para cierre',
action: 'Revisar la evidencia de verificación y registrar la conclusión administrativa.',
dueOn: null,
overdue: false,
};
}
if (latest?.outcome === 'NOT_RESOLVED' && !finding.nextControlOn) {
return {
stage: 'RESCHEDULE_VERIFICATION',
label: 'Requiere nueva verificación',
action: 'Definir una nueva fecha de verificación para devolver el hallazgo a planificación.',
dueOn: null,
overdue: false,
};
}
if (!finding.companyResponseReceivedOn) {
const overdue = Boolean(finding.correctionDueOn && finding.correctionDueOn < today);
return {
stage: overdue ? 'COMPANY_OVERDUE' : 'WAITING_COMPANY',
label: overdue ? 'Respuesta de empresa vencida' : 'Esperando respuesta de empresa',
action: overdue
? 'Registrar la presentación recibida o gestionar el reclamo administrativo.'
: 'Registrar la respuesta de la empresa cuando sea recibida.',
dueOn: finding.correctionDueOn,
overdue,
};
}
if (!finding.nextControlOn) {
return {
stage: 'DEFINE_VERIFICATION',
label: 'Definir fecha de verificación',
action: 'Establecer cuándo debe volver a controlarse el hallazgo.',
dueOn: null,
overdue: false,
};
}
if (finding.verificationVisit) {
return {
stage: 'VERIFICATION_PLANNED',
label: 'Verificación planificada',
action: 'La visita ya está vinculada. Consultar o completar la inspección de verificación.',
dueOn: finding.nextControlOn,
overdue: finding.nextControlOn < today,
};
}
return {
stage: 'PLAN_VERIFICATION',
label: finding.nextControlOn < today ? 'Verificación vencida sin visita' : 'Verificación pendiente de planificación',
action: 'Crear una visita de verificación para la fecha operativa definida.',
dueOn: finding.nextControlOn,
overdue: finding.nextControlOn < today,
};
}
private mendozaDate(): string {
return new Intl.DateTimeFormat('en-CA', {
timeZone: 'America/Argentina/Mendoza',
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).format(new Date());
}
private async requireAct(manager: EntityManager, id: string): Promise<void> {
const rows = await manager.query('SELECT 1 FROM inspection_acts WHERE id = $1', [id]) as unknown[];
if (rows.length === 0) throw actNotFound();
}
private async lockAct(manager: EntityManager, id: string): Promise<InspectionAct> {
const act = await manager.getRepository(InspectionAct)
.createQueryBuilder('act')
.where('act.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!act) throw actNotFound();
return act;
}
private async lockVisit(manager: EntityManager, id: string): Promise<InspectionVisit> {
const visit = await manager.getRepository(InspectionVisit)
.createQueryBuilder('visit')
.where('visit.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!visit) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
return visit;
}
private async lockFinding(manager: EntityManager, id: string): Promise<InspectionFinding> {
const finding = await manager.getRepository(InspectionFinding)
.createQueryBuilder('finding')
.where('finding.id = :id', { id })
.setLock('pessimistic_write')
.getOne();
if (!finding) throw findingNotFound();
return finding;
}
private assertActEditable(act: InspectionAct, visit: InspectionVisit): void {
if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_FINDING_ACT_NOT_EDITABLE',
message: 'Los hallazgos sólo se editan mientras el acta está en borrador y la visita en curso',
});
}
}
private assertFindingOpen(finding: InspectionFinding): void {
if (finding.status !== InspectionFindingStatus.OPEN) {
throw new ConflictException({
code: 'INSPECTION_FINDING_NOT_OPEN',
message: 'El hallazgo ya no está abierto',
});
}
}
private async assertActorAssigned(
manager: EntityManager,
visitId: string,
principal: AuthPrincipal,
): Promise<void> {
if (principal.permissions.includes('inspections.manage')) return;
const rows = await manager.query(`
SELECT 1 FROM inspection_visit_members
WHERE visit_id = $1 AND user_id = $2 AND included = true
`, [visitId, principal.userId]) as unknown[];
if (rows.length === 0) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La visita no está asignada al usuario actual',
});
}
}
private async assertActAsset(manager: EntityManager, actId: string, assetId: string): Promise<void> {
const rows = await manager.query(`
SELECT 1 FROM inspection_act_assets
WHERE act_id = $1 AND asset_id = $2 AND included = true
`, [actId, assetId]) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'INSPECTION_FINDING_ASSET_INVALID',
message: 'El activo del hallazgo debe estar incluido en el acta',
});
}
}
private async requireCatalogItem(
manager: EntityManager,
id: string,
assetId: string,
): Promise<CatalogSnapshot> {
const [item] = await manager.query(`
SELECT
item.id,
item.title,
item.legal_basis AS "legalBasis",
item.glossary,
item.revision,
item.suggested_severity AS "suggestedSeverity"
FROM finding_catalog_items item
INNER JOIN finding_categories category ON category.id = item.category_id
INNER JOIN assets asset ON asset.id = $2
LEFT JOIN finding_catalog_asset_type_profiles profile
ON profile.asset_type_id = asset.asset_type_id
LEFT JOIN finding_catalog_item_asset_types mapping
ON mapping.catalog_item_id = item.id
AND mapping.asset_type_id = asset.asset_type_id
LEFT JOIN finding_catalog_asset_overrides override
ON override.catalog_item_id = item.id
AND override.asset_id = asset.id
WHERE item.id = $1
AND item.is_active = true
AND category.is_active = true
AND COALESCE(
override.is_enabled,
CASE WHEN profile.asset_type_id IS NULL THEN true ELSE mapping.id IS NOT NULL END
) = true
`, [id, assetId]) as CatalogSnapshot[];
if (!item) {
throw new BadRequestException({
code: 'FINDING_CATALOG_ITEM_NOT_APPLICABLE',
message: 'El hallazgo seleccionado no está habilitado para este elemento del Inventario',
});
}
return item;
}
private async allocateFindingNumber(manager: EntityManager, actId: string): Promise<number> {
const [row] = 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 }>;
return Number(row.number);
}
private async captureVersion(
manager: EntityManager,
finding: InspectionFinding,
event: InspectionFindingVersionEvent,
principal: AuthPrincipal,
): Promise<number> {
const [row] = await manager.query(`
UPDATE inspection_findings
SET current_version = current_version + 1
WHERE id = $1
RETURNING current_version AS "versionNumber"
`, [finding.id]) as Array<{ versionNumber: number }>;
const versionNumber = Number(row.versionNumber);
finding.currentVersion = versionNumber;
const snapshot = await this.buildSnapshot(manager, finding.id);
await manager.query(`
INSERT INTO inspection_finding_versions (
finding_id, version_number, event, snapshot, actor_user_id, actor_username
) VALUES ($1, $2, $3, $4, $5, $6)
`, [finding.id, versionNumber, event, snapshot, principal.userId, principal.username]);
return versionNumber;
}
private async buildSnapshot(
manager: EntityManager,
findingId: string,
): Promise<Record<string, unknown>> {
const [row] = await manager.query(`
SELECT JSONB_BUILD_OBJECT(
'id', finding.id,
'code', finding.code,
'findingNumber', finding.finding_number,
'status', finding.status,
'title', finding.title,
'description', finding.description,
'legalBasis', finding.legal_basis,
'glossary', finding.glossary,
'catalogRevision', finding.catalog_revision,
'suggestedSeverity', finding.suggested_severity,
'severity', finding.severity,
'correctionDueOn', finding.correction_due_on,
'responseDueBasis', finding.response_due_basis,
'responseDueDays', finding.response_due_days,
'responseDueBaseOn', finding.response_due_base_on,
'reportNotifiedOn', finding.report_notified_on,
'companyResponse', finding.company_response,
'companyResponseReceivedOn', finding.company_response_received_on,
'companyCommittedCorrectionOn', finding.company_committed_correction_on,
'nextControlOn', finding.next_control_on,
'currentVersion', finding.current_version,
'act', JSONB_BUILD_OBJECT('id', act.id, 'code', act.code, 'status', act.status),
'visit', JSONB_BUILD_OBJECT('id', visit.id, 'code', visit.code, 'status', visit.status),
'asset', JSONB_BUILD_OBJECT(
'id', asset.id, 'code', asset.code, 'name', asset.name,
'typeId', asset.asset_type_id, 'currentVersion', asset.current_version
),
'catalog', CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', catalog.id, 'code', catalog.code, 'sourceNumber', catalog.source_number,
'revision', finding.catalog_revision, 'suggestedSeverity', finding.suggested_severity, 'categoryCode', category.code,
'categoryName', category.name
) END
) AS snapshot
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
INNER JOIN assets asset ON asset.id = finding.asset_id
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
LEFT JOIN finding_categories category ON category.id = catalog.category_id
WHERE finding.id = $1
`, [findingId]) as Array<{ snapshot: Record<string, unknown> }>;
if (!row) throw findingNotFound();
return row.snapshot;
}
private auditView(finding: InspectionFindingListItem): Record<string, unknown> {
return {
actId: finding.actId,
visitId: finding.document.visitId,
assetId: finding.assetId,
catalogItemId: finding.catalogItemId,
code: finding.code,
status: finding.status,
title: finding.title,
description: finding.description,
suggestedSeverity: finding.suggestedSeverity,
severity: finding.severity,
correctionDueOn: finding.correctionDueOn,
responseDueBasis: finding.responseDueBasis,
responseDueDays: finding.responseDueDays,
responseDueBaseOn: finding.responseDueBaseOn,
reportNotifiedOn: finding.reportNotifiedOn,
companyResponse: finding.companyResponse,
companyResponseReceivedOn: finding.companyResponseReceivedOn,
companyCommittedCorrectionOn: finding.companyCommittedCorrectionOn,
nextControlOn: finding.nextControlOn,
currentVersion: finding.currentVersion,
closedAt: finding.closedAt,
closureNotes: finding.closureNotes,
};
}
}