import { 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, InspectionActStatus, InspectionActVersionEvent, InspectionVisitStatus, } from '../database/entities'; import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { sha256CanonicalJson } from './canonical-json'; import type { LockInspectionActDto } from './dto/lock-inspection-act.dto'; const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2'; interface LockContext { id: string; code: string; visitId: string; status: InspectionActStatus; occurredAt: Date; currentVersion: number; visitStatus: InspectionVisitStatus; leadInspectorUserId: string; } @Injectable() export class InspectionActLifecycleService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly deadlines: InspectionDeadlinesService, ) {} async lock( actId: string, dto: LockInspectionActDto, principal: AuthPrincipal, request: RequestWithContext, ) { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const context = await this.lockContext(manager, actId); if (context.status !== InspectionActStatus.DRAFT) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_DRAFT', message: 'Sólo puede finalizarse y bloquearse un Acta que esté en borrador', }); } if (context.visitStatus !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_VISIT_NOT_IN_PROGRESS', message: 'El Acta sólo puede bloquearse durante una inspección en curso', }); } await this.assertActorAssigned(manager, context, principal); await this.requireResponsible(manager, actId); await this.requireVerificationResults(manager, context.visitId); const [signatureCount] = (await manager.query(` SELECT COUNT(*)::integer AS total FROM inspection_act_signatures WHERE act_id = $1 `, [actId])) as Array<{ total: number }>; if (Number(signatureCount?.total ?? 0) > 0) { throw new ConflictException({ code: 'INSPECTION_ACT_ALREADY_SIGNED', message: 'El Acta ya tiene una manifestación de firma y no puede volver a bloquearse', }); } const deadline = await this.deadlines.snapshotForLock( manager, dto.urgency, new Date(context.occurredAt), ); const lockedAt = new Date(); const [updated] = (await manager.query(` UPDATE inspection_acts SET status = 'READY', urgency = $2, deadline_days = $3, deadline_day_type = $4, deadline_basis = $5, deadline_base_on = $6::date, deadline_due_on = $7::date, deadline_policy_snapshot = $8::jsonb, locked_at = $9, locked_by = $10, current_version = current_version + 1, updated_by = $10, updated_at = CURRENT_TIMESTAMP WHERE id = $1 RETURNING current_version AS "versionNumber" `, [ actId, dto.urgency, deadline.snapshot.days, deadline.snapshot.dayType, deadline.snapshot.basis, deadline.baseOn, deadline.dueOn, deadline.snapshot, lockedAt, principal.userId, ])) as Array<{ versionNumber: number }>; const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, lockedAt); const preparedSha256 = sha256CanonicalJson(preparedSnapshot); await manager.query(` UPDATE inspection_acts SET locked_sha256 = $2 WHERE id = $1 `, [actId, preparedSha256]); await manager.query(` INSERT INTO inspection_act_closures ( act_id, schema_version, prepared_snapshot, prepared_sha256, prepared_at, prepared_by ) VALUES ($1, $2, $3, $4, $5, $6) ON CONFLICT (act_id) DO UPDATE SET schema_version = EXCLUDED.schema_version, prepared_snapshot = EXCLUDED.prepared_snapshot, prepared_sha256 = EXCLUDED.prepared_sha256, prepared_at = EXCLUDED.prepared_at, prepared_by = EXCLUDED.prepared_by `, [ actId, CLOSURE_SCHEMA_VERSION, preparedSnapshot, preparedSha256, lockedAt, principal.userId, ]); await manager.query(` INSERT INTO inspection_act_versions ( act_id, version_number, event, snapshot, actor_user_id, actor_username ) VALUES ($1, $2, $3, $4, $5, $6) `, [ actId, Number(updated.versionNumber), InspectionActVersionEvent.READY, preparedSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_READY, entityType: 'inspection_act', entityId: actId, afterData: { status: 'LOCKED_PENDING_SIGNATURE', physicalStatus: InspectionActStatus.READY, urgency: dto.urgency, deadlineDays: deadline.snapshot.days, deadlineDayType: deadline.snapshot.dayType, deadlineBasis: deadline.snapshot.basis, deadlineBaseOn: deadline.baseOn, deadlineDueOn: deadline.dueOn, lockedAt: lockedAt.toISOString(), preparedSha256, }, metadata: { actId, visitId: context.visitId, versionNumber: Number(updated.versionNumber), immutable: true, }, }, manager); return { id: actId, code: context.code, status: 'LOCKED_PENDING_SIGNATURE' as const, physicalStatus: InspectionActStatus.READY, urgency: dto.urgency, deadline: { days: deadline.snapshot.days, dayType: deadline.snapshot.dayType, basis: deadline.snapshot.basis, baseOn: deadline.baseOn, dueOn: deadline.dueOn, }, lockedAt, preparedSha256, currentVersion: Number(updated.versionNumber), }; }); } private async lockContext(manager: EntityManager, actId: string): Promise { const [row] = (await manager.query(` SELECT act.id, act.code, act.visit_id AS "visitId", act.status, act.occurred_at AS "occurredAt", act.current_version AS "currentVersion", visit.status AS "visitStatus", visit.lead_inspector_user_id AS "leadInspectorUserId" 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 LockContext[]; if (!row) { throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta de inspección no encontrada', }); } return row; } private async assertActorAssigned( manager: EntityManager, context: LockContext, principal: AuthPrincipal, ): Promise { if (context.leadInspectorUserId === principal.userId) return; const [membership] = (await manager.query(` SELECT 1 AS found FROM inspection_visit_members WHERE visit_id = $1 AND user_id = $2 AND included = true LIMIT 1 `, [context.visitId, principal.userId])) as Array<{ found: number }>; if (!membership) { throw new ForbiddenException({ code: 'INSPECTION_ACT_ACTOR_NOT_ASSIGNED', message: 'Sólo un inspector asignado a la inspección puede finalizar el Acta', }); } } private async requireResponsible(manager: EntityManager, actId: string): Promise { const [row] = (await manager.query(` SELECT act_id FROM inspection_act_responsibles WHERE act_id = $1 `, [actId])) as Array<{ act_id: string }>; if (!row) { throw new ConflictException({ code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED', message: 'Antes de finalizar el Acta debe identificarse al responsable de la empresa o documentar su ausencia', }); } } private async requireVerificationResults(manager: EntityManager, visitId: string): Promise { const [verification] = (await manager.query(` SELECT COUNT(*)::integer AS total, COUNT(*) FILTER (WHERE outcome IS NOT NULL)::integer AS completed FROM inspection_finding_verification_visits WHERE visit_id = $1 `, [visitId])) as Array<{ total: number; completed: number }>; const total = Number(verification?.total ?? 0); const completed = Number(verification?.completed ?? 0); if (total > 0 && completed !== total) { throw new ConflictException({ code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED', message: 'Registrá el resultado de todas las verificaciones antes de finalizar el Acta', }); } } private async buildPreparedSnapshot( manager: EntityManager, actId: string, preparedAt: Date, ): Promise> { const [act] = (await manager.query(` SELECT act.id, act.code, act.act_year AS "actYear", act.act_number AS "actNumber", act.status, act.occurred_at AS "occurredAt", act.title, act.summary, act.observations, act.urgency, act.deadline_days AS "deadlineDays", act.deadline_day_type AS "deadlineDayType", act.deadline_basis AS "deadlineBasis", act.deadline_base_on AS "deadlineBaseOn", act.deadline_due_on AS "deadlineDueOn", act.deadline_policy_snapshot AS "deadlinePolicySnapshot", act.locked_at AS "lockedAt", act.current_version AS "currentVersion", act.created_at AS "createdAt", act.updated_at AS "updatedAt", JSONB_BUILD_OBJECT( 'id', visit.id, 'code', visit.code, 'title', visit.title, 'objective', visit.objective, 'status', visit.status, 'scopeAssetId', visit.scope_asset_id, 'leadInspectorUserId', visit.lead_inspector_user_id, 'plannedStartAt', visit.planned_start_at, 'plannedEndAt', visit.planned_end_at, 'actualStartedAt', visit.actual_started_at, 'instructions', visit.instructions ) AS visit FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.visit_id WHERE act.id = $1 `, [actId])) as Array>; if (!act) { throw new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta de inspección no encontrada', }); } const [responsible] = (await manager.query(` SELECT act_id AS "actId", attendance_status AS "attendanceStatus", full_name AS "fullName", document_type AS "documentType", document_number AS "documentNumber", position, email, phone, absence_reason AS "absenceReason", updated_by AS "updatedBy", created_at AS "createdAt", updated_at AS "updatedAt" FROM inspection_act_responsibles WHERE act_id = $1 `, [actId])) as Array>; const team = await manager.query(` SELECT member.user_id AS "userId", user_account.username, user_account.first_name AS "firstName", user_account.last_name AS "lastName", user_account.email, (visit.lead_inspector_user_id = member.user_id) AS "isLead" FROM inspection_visit_members member INNER JOIN inspection_visits visit ON visit.id = member.visit_id INNER JOIN users user_account ON user_account.id = member.user_id WHERE member.visit_id = $1 AND member.included = true ORDER BY "isLead" DESC, user_account.username, member.user_id `, [(act.visit as { id: string }).id]) as Array>; const assets = await manager.query(` SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset.description, asset.parent_id AS "parentId", CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', company.id, 'code', company.code, 'name', company.name, 'commonName', company.common_name ) END AS "operatorCompany", CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', area.id, 'code', area.code, 'name', area.name, 'commonName', area.common_name ) END AS "operationalArea", asset.information_status AS "informationStatus", asset.current_version AS "currentVersion", asset.data_origin AS "dataOrigin", asset.source_name AS "sourceName", asset.source_reference AS "sourceReference", asset.source_observed_at AS "sourceObservedAt", asset_type.id AS "typeId", asset_type.code AS "typeCode", asset_type.name AS "typeName", COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'definitionId', definition.id, 'code', definition.code, 'name', definition.name, 'dataType', definition.data_type, 'value', attribute_value.value ) ORDER BY definition.sort_order, definition.code, definition.id) FROM asset_attribute_values attribute_value INNER JOIN asset_attribute_definitions definition ON definition.id = attribute_value.definition_id WHERE attribute_value.asset_id = asset.id ), '[]'::jsonb) AS attributes, CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'type', geometry.geometry_type, 'geojson', ST_AsGeoJSON(geometry.geometry)::jsonb, 'source', geometry.source, 'accuracyM', geometry.accuracy_m, 'capturedAt', geometry.captured_at, 'deviceLabel', geometry.device_label ) END AS geometry FROM inspection_act_assets link INNER JOIN assets asset ON asset.id = link.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 asset_geometries geometry ON geometry.asset_id = asset.id WHERE link.act_id = $1 AND link.included = true ORDER BY asset.code, asset.id `, [actId]) as Array>; const findings = await manager.query(` SELECT finding.id, finding.finding_number AS "findingNumber", finding.code, finding.status, finding.asset_id AS "assetId", finding.catalog_item_id AS "catalogItemId", finding.title, finding.description, finding.legal_basis AS "legalBasis", finding.glossary, finding.catalog_revision AS "catalogRevision", finding.suggested_severity AS "suggestedSeverity", finding.severity, finding.is_recurrence AS "isRecurrence", finding.antecedent_finding_id AS "antecedentFindingId", finding.correction_due_on AS "correctionDueOn", finding.next_control_on AS "nextControlOn", finding.current_version AS "currentVersion", JSONB_BUILD_OBJECT( 'code', catalog.code, 'sourceNumber', catalog.source_number, 'title', catalog.title, 'revision', catalog.revision, 'suggestedSeverity', finding.suggested_severity, 'categoryCode', category.code, 'categoryName', category.name ) AS catalog, COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'id', evidence.id, 'communicationId', evidence.communication_id, 'kind', evidence.kind, 'purpose', evidence.purpose, 'originalName', evidence.original_name, 'mimeType', evidence.mime_type, 'sizeBytes', evidence.size_bytes, 'sha256', evidence.sha256, 'title', evidence.title, 'description', evidence.description, 'capturedAt', evidence.captured_at, 'latitude', evidence.latitude, 'longitude', evidence.longitude, 'accuracyM', evidence.accuracy_m, 'deviceLabel', evidence.device_label, 'source', evidence.source, 'uploadedBy', evidence.uploaded_by, 'createdAt', evidence.created_at ) ORDER BY evidence.created_at, evidence.id) FROM inspection_finding_evidence evidence WHERE evidence.finding_id = finding.id ), '[]'::jsonb) AS evidence FROM inspection_findings finding 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.act_id = $1 AND finding.status <> 'VOIDED' ORDER BY finding.finding_number, finding.id `, [actId]) as Array>; const verificationResults = await manager.query(` SELECT verification_link.finding_id AS "findingId", finding.code AS "findingCode", finding.title AS "findingTitle", finding.description AS "findingDescription", finding.asset_id AS "assetId", asset.code AS "assetCode", asset.name AS "assetName", verification_link.target_control_on AS "targetControlOn", verification_link.outcome, verification_link.result_notes AS "resultNotes", verification_link.verified_at AS "verifiedAt", verification_link.result_recorded_at AS "resultRecordedAt", verification_link.rescheduled_control_on AS "rescheduledControlOn" FROM inspection_finding_verification_visits verification_link INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id INNER JOIN assets asset ON asset.id = finding.asset_id WHERE verification_link.visit_id = $1 ORDER BY finding.code `, [(act.visit as { id: string }).id]) as Array>; return { schemaVersion: CLOSURE_SCHEMA_VERSION, preparedAt: preparedAt.toISOString(), act, responsible: responsible ?? null, team, assets, findings, verificationResults, }; } }