From f23a28e4380d36f1ec3b872d351206909966778e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:54:53 -0300 Subject: [PATCH] refactor(f4): replace READY/CLOSED lifecycle with LOCKED/SEALED --- .../inspection-closing.service.ts | 1093 +++++++---------- 1 file changed, 454 insertions(+), 639 deletions(-) diff --git a/api-v3/src/inspection-closing/inspection-closing.service.ts b/api-v3/src/inspection-closing/inspection-closing.service.ts index be3c131..8953f74 100644 --- a/api-v3/src/inspection-closing/inspection-closing.service.ts +++ b/api-v3/src/inspection-closing/inspection-closing.service.ts @@ -14,7 +14,6 @@ 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 { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { AuditAction, InspectionAct, @@ -23,28 +22,32 @@ import { InspectionActSignerType, InspectionCompanySignatureManifestation, InspectionActStatus, + InspectionActUrgency, + InspectionDeadlineBasis, + InspectionDeadlineDayType, InspectionActUploadMode, InspectionActVersionEvent, InspectionResponsibleAttendanceStatus, InspectionVisit, InspectionVisitStatus, } from '../database/entities'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import { sha256CanonicalJson } from './canonical-json'; import type { CloseInspectionActDto } from './dto/close-inspection-act.dto'; -import { InspectionReportsService } from '../inspection-reports/inspection-reports.service'; import type { CreateCompanyOutcomeDto } from './dto/create-company-outcome.dto'; -import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { CreateCompanySignatureDto } from './dto/create-company-signature.dto'; +import type { CreateInspectionSignatureDto } from './dto/create-inspection-signature.dto'; import type { UpsertInspectionResponsibleDto } from './dto/upsert-inspection-responsible.dto'; import { inspectInspectionSignatureFile, type UploadedInspectionSignatureFile, } from './inspection-signature-file'; -const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2'; -const CONSENT_VERSION = 'D5-1'; -const INSPECTOR_CONSENT = 'Declaro que esta firma manuscrita fue realizada por mí y se incorpora al acta como constancia de mi intervención y conformidad con su contenido.'; -const COMPANY_CONSENT = 'Declaro haber leído o recibido explicación del contenido del acta y que esta firma manuscrita se incorpora como constancia de recepción, sin implicar aceptación de los hallazgos.'; +const CLOSURE_SCHEMA_VERSION = 'DH-ACT-LIFECYCLE-V4'; +const CONSENT_VERSION = 'F4-1'; +const INSPECTOR_CONSENT = 'Declaro que revisé el contenido del acta bloqueada y que esta firma deja constancia de mi intervención como inspector/a.'; +const COMPANY_CONSENT = 'Declaro haber accedido al contenido íntegro del acta bloqueada y que esta firma electrónica deja constancia de mi recepción y manifestación, sin alterar el contenido del acta.'; interface ResponsibleView { actId: string; @@ -114,12 +117,28 @@ interface ClosureRecord { closedBy: string | null; } +interface DeadlinePolicy { + urgentDays: number; + urgentDayType: InspectionDeadlineDayType; + nonUrgentDays: number; + nonUrgentDayType: InspectionDeadlineDayType; +} + export interface InspectionClosureView { act: { id: string; code: string; status: InspectionActStatus; visitId: string; + urgency: InspectionActUrgency; + deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; + deadlineBasis: InspectionDeadlineBasis | null; + deadlineBaseAt: Date | null; + deadlineAt: Date | null; + lockedAt: Date | null; + lockedSha256: string | null; + sealedAt: Date | null; currentVersion: number; closedAt: Date | null; closedBy: string | null; @@ -208,17 +227,18 @@ export class InspectionClosingService { INSERT INTO inspection_act_responsibles ( act_id, attendance_status, full_name, document_type, document_number, position, email, phone, absence_reason, updated_by - ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT (act_id) DO UPDATE SET - attendance_status = EXCLUDED.attendance_status, - full_name = EXCLUDED.full_name, - document_type = EXCLUDED.document_type, - document_number = EXCLUDED.document_number, - position = EXCLUDED.position, - email = EXCLUDED.email, - phone = EXCLUDED.phone, - absence_reason = EXCLUDED.absence_reason, - updated_by = EXCLUDED.updated_by + attendance_status=EXCLUDED.attendance_status, + full_name=EXCLUDED.full_name, + document_type=EXCLUDED.document_type, + document_number=EXCLUDED.document_number, + position=EXCLUDED.position, + email=EXCLUDED.email, + phone=EXCLUDED.phone, + absence_reason=EXCLUDED.absence_reason, + updated_by=EXCLUDED.updated_by, + updated_at=CURRENT_TIMESTAMP `, [ actId, dto.attendanceStatus, @@ -239,12 +259,13 @@ export class InspectionClosingService { entityId: actId, beforeData: before as unknown as Record | null, afterData: after as unknown as Record, - metadata: { actId, visitId: visit.id }, + metadata: { actId, inspectionId: visit.id }, }, manager); return this.loadView(manager, actId); }); } + /** Finaliza el contenido: desde este punto el Acta queda inmutable. */ async prepare( actId: string, principal: AuthPrincipal, @@ -256,157 +277,127 @@ export class InspectionClosingService { this.assertDraftInProgress(act, visit); await this.assertActorAssigned(manager, visit.id, principal, true); await this.requireResponsible(manager, actId); - const [count] = (await manager.query(` - SELECT COUNT(*)::integer AS total - FROM inspection_findings - WHERE act_id = $1 AND status <> 'VOIDED' - `, [actId])) as Array<{ total: number }>; - const [verification] = (await manager.query(` - SELECT - COUNT(*)::integer AS total, - COUNT(*) FILTER (WHERE verification_link.outcome IS NOT NULL)::integer AS completed - FROM inspection_finding_verification_visits verification_link - WHERE verification_link.visit_id = $1 - `, [visit.id])) as Array<{ total: number; completed: number }>; - const verificationTotal = Number(verification?.total ?? 0); - const verificationCompleted = Number(verification?.completed ?? 0); - if (verificationTotal > 0 && verificationCompleted !== verificationTotal) { - throw new ConflictException({ - code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED', - message: 'Registrá el resultado de todos los hallazgos a verificar antes de preparar el acta', - }); - } - if (Number(count?.total ?? 0) < 1 && verificationTotal < 1) { - throw new ConflictException({ - code: 'INSPECTION_ACT_FINDING_REQUIRED', - message: 'El acta debe contener al menos un hallazgo o una verificación registrada antes de prepararse', - }); - } - const signatures = await this.signatureCount(manager, actId); - if (signatures > 0) { + await this.assertVerificationResultsComplete(manager, visit.id); + if (await this.signatureCount(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_ALREADY_SIGNED', - message: 'El acta ya tiene firmas y no puede volver a prepararse', + message: 'El acta ya tiene constancias de firma y no puede volver a finalizarse', }); } + + const policy = await this.deadlinePolicy(manager); + const deadlineDays = act.urgency === InspectionActUrgency.URGENT + ? policy.urgentDays + : policy.nonUrgentDays; + const deadlineDayType = act.urgency === InspectionActUrgency.URGENT + ? policy.urgentDayType + : policy.nonUrgentDayType; + const deadlineBasis = act.urgency === InspectionActUrgency.URGENT + ? InspectionDeadlineBasis.ACT_DATE + : InspectionDeadlineBasis.GEDO_DATE; + const deadlineBaseAt = act.urgency === InspectionActUrgency.URGENT + ? act.occurredAt + : null; + const deadlineAt = deadlineBaseAt + ? await this.calculateDeadline(manager, deadlineBaseAt, deadlineDays, deadlineDayType) + : null; + const lockedAt = new Date(); + const [updated] = (await manager.query(` UPDATE inspection_acts - SET status = 'READY', - current_version = current_version + 1, - updated_by = $2, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 + SET status='LOCKED', + deadline_days=$2, + deadline_day_type=$3, + deadline_basis=$4, + deadline_base_at=$5, + deadline_at=$6, + locked_at=$7, + locked_by=$8, + current_version=current_version+1, + updated_by=$8, + updated_at=$7 + WHERE id=$1 RETURNING current_version AS "versionNumber" - `, [actId, principal.userId])) as Array<{ versionNumber: number }>; - const preparedAt = new Date(); - const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, preparedAt); - const preparedSha256 = sha256CanonicalJson(preparedSnapshot); - 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, - preparedAt, + deadlineDays, + deadlineDayType, + deadlineBasis, + deadlineBaseAt, + deadlineAt, + lockedAt, principal.userId, - ]); + ])) as Array<{ versionNumber: number }>; + + const preparedSnapshot = await this.buildLockedSnapshot(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, + final_snapshot=NULL, + final_sha256=NULL, + device_closed_at=NULL, + server_closed_at=NULL, + upload_mode=NULL, + closed_by=NULL + `, [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) + act_id,version_number,event,snapshot,actor_user_id,actor_username + ) VALUES ($1,$2,$3,$4,$5,$6) `, [ actId, Number(updated.versionNumber), - InspectionActVersionEvent.READY, + InspectionActVersionEvent.LOCKED, preparedSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_ACT_READY, + action: AuditAction.INSPECTION_ACT_LOCKED, entityType: 'inspection_act', entityId: actId, afterData: { - status: InspectionActStatus.READY, - preparedSha256, - schemaVersion: CLOSURE_SCHEMA_VERSION, + status: InspectionActStatus.LOCKED, + lockedSha256: preparedSha256, + urgency: act.urgency, + deadlineDays, + deadlineDayType, + deadlineBasis, + deadlineBaseAt, + deadlineAt, }, - metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, + metadata: { actId, inspectionId: visit.id, versionNumber: Number(updated.versionNumber) }, }, manager); return this.loadView(manager, actId); }); } + /** + * F4 elimina la reapertura normal: Finalizar Acta significa inmovilizar su contenido. + * Una eventual rectificación administrativa debe ser otro acto auditado, no volver a DRAFT. + */ async reopen( - actId: string, - principal: AuthPrincipal, - request: RequestWithContext, + _actId: string, + _principal: AuthPrincipal, + _request: RequestWithContext, ): Promise { - assertMobileInspector(principal); - return this.dataSource.transaction(async (manager) => { - const { act, visit } = await this.lockContext(manager, actId); - if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { - throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY', - message: 'Sólo puede reabrirse un acta preparada de una visita en curso', - }); - } - await this.assertActorAssigned(manager, visit.id, principal, true); - if (await this.signatureCount(manager, actId)) { - throw new ConflictException({ - code: 'INSPECTION_ACT_SIGNED_CANNOT_REOPEN', - message: 'El acta ya tiene firmas y no puede volver a borrador', - }); - } - const closure = await this.requireClosure(manager, actId); - const [updated] = (await manager.query(` - UPDATE inspection_acts - SET status = 'DRAFT', - current_version = current_version + 1, - updated_by = $2, - updated_at = CURRENT_TIMESTAMP - WHERE id = $1 - RETURNING current_version AS "versionNumber" - `, [actId, principal.userId])) as Array<{ versionNumber: number }>; - const snapshot = { - actId, - actCode: act.code, - status: InspectionActStatus.DRAFT, - reopenedAt: new Date().toISOString(), - previousPreparedSha256: closure.preparedSha256, - }; - 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.REOPENED, - snapshot, - principal.userId, - principal.username, - ]); - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_ACT_REOPENED, - entityType: 'inspection_act', - entityId: actId, - afterData: snapshot, - metadata: { actId, visitId: visit.id, versionNumber: Number(updated.versionNumber) }, - }, manager); - return this.loadView(manager, actId); + throw new ConflictException({ + code: 'INSPECTION_ACT_LOCK_IS_IMMUTABLE', + message: 'Un acta finalizada es inmutable y no puede volver a borrador', }); } @@ -418,17 +409,22 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const identity = { - signerType: InspectionActSignerType.INSPECTOR, - signerUserId: principal.userId, - signerName: `${principal.firstName} ${principal.lastName}`.trim(), - documentType: null, - documentNumber: null, - position: 'Inspector/a', - consentText: INSPECTOR_CONSENT, - auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, - } as const; - return this.createSignedSignature(actId, dto, file, identity, principal, request, true); + return this.createSignedSignature( + actId, + dto, + file, + { + signerType: InspectionActSignerType.INSPECTOR, + signerUserId: principal.userId, + signerName: `${principal.firstName} ${principal.lastName}`.trim(), + documentType: null, + documentNumber: null, + position: 'Inspector/a', + consentText: INSPECTOR_CONSENT, + }, + principal, + request, + ); } async signCompany( @@ -439,7 +435,7 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - return this.createSignedSignature(actId, dto, file, null, principal, request, false); + return this.createSignedSignature(actId, dto, file, null, principal, request); } async recordCompanyOutcome( @@ -451,40 +447,29 @@ export class InspectionClosingService { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - this.assertReadyForCompanyOutcome(act, visit); + this.assertLockedForManifestation(act); await this.assertActorAssigned(manager, visit.id, principal, true); const responsible = await this.requireResponsible(manager, actId); - if ( - dto.status === InspectionActSignatureStatus.REFUSED - && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT - ) { + if (dto.status === InspectionActSignatureStatus.REFUSED + && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT) { throw new BadRequestException({ code: 'INSPECTION_COMPANY_REFUSAL_REQUIRES_PRESENCE', - message: 'La negativa a firmar requiere que el responsable esté presente', + message: 'La negativa a firmar requiere identificar al responsable que se negó', }); } - if ( - dto.status === InspectionActSignatureStatus.ABSENT - && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.ABSENT - ) { - throw new BadRequestException({ - code: 'INSPECTION_COMPANY_ABSENCE_MISMATCH', - message: 'El resultado ausente requiere que el responsable se haya registrado como ausente', - }); - } - const closure = await this.requireClosure(manager, actId); if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', - message: 'La recepción de la empresa ya fue registrada y es inmutable', + message: 'La manifestación de la empresa ya fue registrada y es inmutable', }); } - const source = this.signatureSource(principal); + const closure = await this.requireClosure(manager, actId); const createdAt = new Date(); - const signerName = responsible.fullName ?? 'Responsable de la empresa no presente'; + const source = this.signatureSource(principal); + const signerName = responsible.fullName ?? 'Responsable no presente'; const payload = { actId, - preparedSha256: closure.preparedSha256, + lockedSha256: closure.preparedSha256, signerType: InspectionActSignerType.COMPANY_RESPONSIBLE, signerName, documentType: responsible.documentType, @@ -493,20 +478,17 @@ export class InspectionClosingService { status: dto.status, reason: dto.reason, source, - uploadedBy: principal.userId, + recordedBy: principal.userId, createdAt: createdAt.toISOString(), }; const signaturePayloadSha256 = sha256CanonicalJson(payload); const id = randomUUID(); await manager.query(` INSERT INTO inspection_act_signatures ( - id, act_id, signer_type, signer_user_id, signer_name, - document_type, document_number, position, status, reason, - source, prepared_sha256, signature_payload_sha256, uploaded_by, created_at - ) VALUES ( - $1, $2, $3, NULL, $4, $5, $6, $7, $8, $9, - $10, $11, $12, $13, $14 - ) + id,act_id,signer_type,signer_user_id,signer_name, + document_type,document_number,position,status,reason, + source,prepared_sha256,signature_payload_sha256,uploaded_by,created_at + ) VALUES ($1,$2,$3,NULL,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14) `, [ id, actId, @@ -529,12 +511,16 @@ export class InspectionClosingService { entityType: 'inspection_act_signature', entityId: id, afterData: payload, - metadata: { actId, visitId: visit.id, immutable: true, signaturePayloadSha256 }, + metadata: { actId, inspectionId: visit.id, immutable: true }, }, manager); return this.loadView(manager, actId); }); } + /** + * Endpoint legacy /close conservado durante la transición de contratos. + * En F4 su semántica es SELLAR, no volver editable ni cerrar la Inspección. + */ async close( actId: string, dto: CloseInspectionActDto, @@ -542,30 +528,42 @@ export class InspectionClosingService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const closed = await this.dataSource.transaction(async (manager) => { + const sealed = await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - this.assertReadyForFinalClosure(act, visit); + this.assertLockedForManifestation(act); await this.assertActorAssigned(manager, visit.id, principal, true); const closure = await this.requireClosure(manager, actId); const signatures = await this.loadSignatures(manager, actId); - if (!signatures.some((item) => ( + const inspectorSigned = signatures.some((item) => item.signerType === InspectionActSignerType.INSPECTOR - && item.status === InspectionActSignatureStatus.SIGNED - ))) { + && item.status === InspectionActSignatureStatus.SIGNED, + ); + if (!inspectorSigned) { throw new ConflictException({ code: 'INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED', - message: 'Se requiere al menos una firma de inspector para cerrar el acta', + message: 'Se requiere la firma del inspector para sellar el acta', }); } - if (signatures.filter((item) => item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE).length !== 1) { + const companyOutcomes = signatures.filter((item) => + item.signerType === InspectionActSignerType.COMPANY_RESPONSIBLE, + ); + if (companyOutcomes.length !== 1) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED', - message: 'Debe registrarse la firma, negativa o ausencia del responsable de la empresa', + message: 'Debe existir exactamente una firma, disidencia o negativa del responsable de la empresa', }); } - const serverClosedAt = new Date(); - const deviceClosedAt = new Date(dto.clientClosedAt); - if (deviceClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { + const companyOutcome = companyOutcomes[0]; + if (companyOutcome.status === InspectionActSignatureStatus.ABSENT) { + throw new ConflictException({ + code: 'INSPECTION_ACT_COMPANY_MANIFESTATION_PENDING', + message: 'La ausencia no reemplaza la firma o negativa; la manifestación de la empresa sigue pendiente', + }); + } + + const serverSealedAt = new Date(); + const deviceSealedAt = new Date(dto.clientClosedAt); + if (deviceSealedAt.getTime() > serverSealedAt.getTime() + 24 * 60 * 60 * 1000) { throw new BadRequestException({ code: 'INSPECTION_ACT_INVALID_DEVICE_TIME', message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro', @@ -601,84 +599,77 @@ export class InspectionClosingService { })); const finalSnapshot = { schemaVersion: CLOSURE_SCHEMA_VERSION, - preparedSha256: closure.preparedSha256, - preparedSnapshot: closure.preparedSnapshot, + lockedSha256: closure.preparedSha256, + lockedSnapshot: closure.preparedSnapshot, signatures: signatureSnapshot, - closure: { - deviceClosedAt: deviceClosedAt.toISOString(), - serverClosedAt: serverClosedAt.toISOString(), + seal: { + deviceSealedAt: deviceSealedAt.toISOString(), + serverSealedAt: serverSealedAt.toISOString(), uploadMode: dto.uploadMode, - closedBy: principal.userId, - closedByUsername: principal.username, + sealedBy: principal.userId, + sealedByUsername: principal.username, }, }; const finalSha256 = sha256CanonicalJson(finalSnapshot); await manager.query(` UPDATE inspection_act_closures - SET final_snapshot = $2, - final_sha256 = $3, - device_closed_at = $4, - server_closed_at = $5, - upload_mode = $6, - closed_by = $7 - WHERE act_id = $1 - `, [ - actId, - finalSnapshot, - finalSha256, - deviceClosedAt, - serverClosedAt, - dto.uploadMode, - principal.userId, - ]); + SET final_snapshot=$2, + final_sha256=$3, + device_closed_at=$4, + server_closed_at=$5, + upload_mode=$6, + closed_by=$7 + WHERE act_id=$1 + `, [actId, finalSnapshot, finalSha256, deviceSealedAt, serverSealedAt, dto.uploadMode, principal.userId]); const [updated] = (await manager.query(` UPDATE inspection_acts - SET status = 'CLOSED', - closed_at = $2, - closed_by = $3, - closure_sha256 = $4, - current_version = current_version + 1, - updated_by = $3, - updated_at = $2 - WHERE id = $1 + SET status='SEALED', + sealed_at=$2, + sealed_by=$3, + closed_at=$2, + closed_by=$3, + closure_sha256=$4, + current_version=current_version+1, + updated_by=$3, + updated_at=$2 + WHERE id=$1 RETURNING current_version AS "versionNumber" - `, [actId, serverClosedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; + `, [actId, serverSealedAt, principal.userId, finalSha256])) as Array<{ versionNumber: number }>; 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) + act_id,version_number,event,snapshot,actor_user_id,actor_username + ) VALUES ($1,$2,$3,$4,$5,$6) `, [ actId, Number(updated.versionNumber), - InspectionActVersionEvent.CLOSED, + InspectionActVersionEvent.SEALED, finalSnapshot, principal.userId, principal.username, ]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_ACT_CLOSED, + action: AuditAction.INSPECTION_ACT_SEALED, entityType: 'inspection_act', entityId: actId, afterData: { - status: InspectionActStatus.CLOSED, + status: InspectionActStatus.SEALED, closureSha256: finalSha256, - serverClosedAt, - uploadMode: dto.uploadMode, + sealedAt: serverSealedAt, }, metadata: { actId, - visitId: visit.id, + inspectionId: visit.id, versionNumber: Number(updated.versionNumber), - findingsRemainOpen: true, - visitRemainsIndependent: true, + findingsRemainIndependent: true, + inspectionRemainsIndependent: true, }, }, manager); await this.reports.ensureFrozenReport(manager, actId, principal, request); return this.loadView(manager, actId); }); await this.reports.ensureWordForAct(actId); - return closed; + return sealed; } async signatureContent(signatureId: string): Promise<{ @@ -720,11 +711,9 @@ export class InspectionClosingService { documentNumber: null; position: string; consentText: string; - auditAction: AuditAction; }, principal: AuthPrincipal, request: RequestWithContext, - requireSelfAssignment: boolean, ): Promise { this.validateCoordinates(dto.latitude, dto.longitude, dto.accuracyM); const inspected = inspectInspectionSignatureFile(file); @@ -737,15 +726,14 @@ export class InspectionClosingService { try { return await this.dataSource.transaction(async (manager) => { const { act, visit } = await this.lockContext(manager, actId); - if (fixedIdentity) this.assertReadyInProgress(act, visit); - else this.assertReadyForCompanyOutcome(act, visit); - await this.assertActorAssigned(manager, visit.id, principal, !requireSelfAssignment); + this.assertLockedForManifestation(act); + await this.assertActorAssigned(manager, visit.id, principal, true); const closure = await this.requireClosure(manager, actId); const responsible = fixedIdentity ? null : await this.requireResponsible(manager, actId); if (responsible && responsible.attendanceStatus !== InspectionResponsibleAttendanceStatus.PRESENT) { throw new ConflictException({ code: 'INSPECTION_COMPANY_RESPONSIBLE_ABSENT', - message: 'No puede registrarse firma de empresa cuando el responsable está ausente', + message: 'No puede registrarse firma de empresa si no se identificó al responsable presente', }); } const identity = fixedIdentity ?? { @@ -756,15 +744,14 @@ export class InspectionClosingService { documentNumber: responsible!.documentNumber, position: responsible!.position, consentText: COMPANY_CONSENT, - auditAction: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, }; if (identity.signerType === InspectionActSignerType.INSPECTOR) { - const [existing] = (await manager.query(` - SELECT 1 - FROM inspection_act_signatures - WHERE act_id = $1 AND signer_type = 'INSPECTOR' AND signer_user_id = $2 - `, [actId, identity.signerUserId])) as unknown[]; - if (existing) { + const existing = await manager.query(` + SELECT 1 FROM inspection_act_signatures + WHERE act_id=$1 AND signer_type='INSPECTOR' AND signer_user_id=$2 + LIMIT 1 + `, [actId, identity.signerUserId]) as unknown[]; + if (existing.length) { throw new ConflictException({ code: 'INSPECTION_ACT_INSPECTOR_ALREADY_SIGNED', message: 'El inspector actual ya firmó esta acta', @@ -773,9 +760,10 @@ export class InspectionClosingService { } else if (await this.hasCompanyOutcome(manager, actId)) { throw new ConflictException({ code: 'INSPECTION_ACT_COMPANY_OUTCOME_ALREADY_RECORDED', - message: 'La recepción de la empresa ya fue registrada y es inmutable', + message: 'La manifestación de la empresa ya fue registrada y es inmutable', }); } + const source = this.signatureSource(principal); const signedAt = new Date(); const clientSignedAt = dto.clientSignedAt ? new Date(dto.clientSignedAt) : null; @@ -788,7 +776,7 @@ export class InspectionClosingService { : null; const payload = { actId, - preparedSha256: closure.preparedSha256, + lockedSha256: closure.preparedSha256, signerType: identity.signerType, signerUserId: identity.signerUserId, signerName: identity.signerName, @@ -814,23 +802,17 @@ export class InspectionClosingService { const signaturePayloadSha256 = sha256CanonicalJson(payload); await manager.query(` INSERT INTO inspection_act_signatures ( - id, act_id, signer_type, signer_user_id, signer_name, - document_type, document_number, position, status, - company_manifestation, company_statement, - original_name, stored_name, mime_type, size_bytes, image_sha256, - consent_text, consent_version, consent_accepted_at, - client_signed_at, signed_at, latitude, longitude, accuracy_m, - device_label, source, prepared_sha256, signature_payload_sha256, - uploaded_by, created_at + id,act_id,signer_type,signer_user_id,signer_name, + document_type,document_number,position,status, + company_manifestation,company_statement, + original_name,stored_name,mime_type,size_bytes,image_sha256, + consent_text,consent_version,consent_accepted_at, + client_signed_at,signed_at,latitude,longitude,accuracy_m, + device_label,source,prepared_sha256,signature_payload_sha256, + uploaded_by,created_at ) VALUES ( - $1, $2, $3, $4, $5, - $6, $7, $8, 'SIGNED', - $9, $10, - $11, $12, $13, $14, $15, - $16, $17, $18, - $19, $20, $21, $22, $23, - $24, $25, $26, $27, - $28, $20 + $1,$2,$3,$4,$5,$6,$7,$8,'SIGNED',$9,$10,$11,$12,$13,$14,$15, + $16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,$27,$28,$20 ) `, [ id, @@ -864,16 +846,11 @@ export class InspectionClosingService { ]); await this.audit.record({ ...administrationAuditContext(principal, request), - action: identity.auditAction, + action: AuditAction.INSPECTION_ACT_SIGNATURE_RECORDED, entityType: 'inspection_act_signature', entityId: id, afterData: payload, - metadata: { - actId, - visitId: visit.id, - immutable: true, - signaturePayloadSha256, - }, + metadata: { actId, inspectionId: visit.id, immutable: true }, }, manager); return this.loadView(manager, actId); }); @@ -883,116 +860,50 @@ export class InspectionClosingService { } } - private async buildPreparedSnapshot( + private async buildLockedSnapshot( manager: EntityManager, actId: string, - preparedAt: Date, + lockedAt: 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.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 + const [act] = await manager.query(` + SELECT JSONB_BUILD_OBJECT( + 'id',act.id, + 'code',act.code, + 'status',act.status, + 'occurredAt',act.occurred_at, + 'title',act.title, + 'summary',act.summary, + 'observations',act.observations, + 'urgency',act.urgency, + 'deadlineDays',act.deadline_days, + 'deadlineDayType',act.deadline_day_type, + 'deadlineBasis',act.deadline_basis, + 'deadlineBaseAt',act.deadline_base_at, + 'deadlineAt',act.deadline_at, + 'inspection',JSONB_BUILD_OBJECT( + 'id',visit.id, + 'code',visit.code, + 'status',visit.status, + 'operationalAreaId',visit.operational_area_id, + 'operatorCompanyId',visit.operator_company_id, + 'leadInspectorUserId',visit.lead_inspector_user_id, + 'actualStartedAt',visit.actual_started_at + ) + ) AS act FROM inspection_acts act - INNER JOIN inspection_visits visit ON visit.id = act.visit_id - WHERE act.id = $1 - `, [actId])) as Array>; + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE act.id=$1 + `, [actId]) as Array<{ act: Record }>; if (!act) throw actNotFound(); const responsible = await this.requireResponsible(manager, actId); - 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 + const inventories = await manager.query(` + SELECT asset.id,asset.code,asset.name,asset.current_version AS "currentVersion", + type.code AS "typeCode",type.name AS "typeName" 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 + JOIN assets asset ON asset.id=link.asset_id + JOIN asset_types type ON type.id=asset.asset_type_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 @@ -1005,159 +916,129 @@ export class InspectionClosingService { 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.recurrence_of_finding_id AS "recurrenceOfFindingId", finding.correction_due_on AS "correctionDueOn", - 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", - 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, - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', communication.id, - 'direction', communication.direction, - 'channel', communication.channel, - 'type', communication.type, - 'occurredAt', communication.occurred_at, - 'subject', communication.subject, - 'details', communication.details, - 'contactName', communication.contact_name, - 'contactEmail', communication.contact_email, - 'createdBy', communication.created_by, - 'createdAt', communication.created_at - ) ORDER BY communication.occurred_at, communication.id) - FROM inspection_finding_communications communication - WHERE communication.finding_id = finding.id - ), '[]'::jsonb) AS communications + finding.current_version AS "currentVersion" 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 + 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", - COALESCE(( - SELECT JSONB_AGG(JSONB_BUILD_OBJECT( - 'id', evidence.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 - AND evidence.verification_visit_id = $1 - AND evidence.purpose = 'VERIFICATION' - ), '[]'::jsonb) AS evidence - 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, + lockedAt: lockedAt.toISOString(), + act: act.act, responsible, - team, - assets, + inventories, findings, - verificationResults, }; } - private async loadView(manager: EntityManager, actId: string): Promise { - const [context] = (await manager.query(` + private async assertVerificationResultsComplete(manager: EntityManager, inspectionId: string): Promise { + const [row] = await manager.query(` SELECT - act.id, - act.code, - act.status, - act.visit_id AS "visitId", + COUNT(*)::integer AS total, + COUNT(*) FILTER (WHERE outcome IS NOT NULL)::integer AS completed + FROM inspection_finding_verification_visits + WHERE visit_id=$1 + `, [inspectionId]) as Array<{ total: number; completed: number }>; + if (Number(row?.total ?? 0) > 0 && Number(row.completed) !== Number(row.total)) { + throw new ConflictException({ + code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED', + message: 'Registrá el resultado de todas las verificaciones antes de finalizar el acta', + }); + } + } + + private async deadlinePolicy(manager: EntityManager): Promise { + const [row] = await manager.query(` + SELECT + urgent_days AS "urgentDays", + urgent_day_type AS "urgentDayType", + non_urgent_days AS "nonUrgentDays", + non_urgent_day_type AS "nonUrgentDayType" + FROM inspection_deadline_policies + ORDER BY created_at + LIMIT 1 + `) as DeadlinePolicy[]; + return row ?? { + urgentDays: 5, + urgentDayType: InspectionDeadlineDayType.BUSINESS, + nonUrgentDays: 10, + nonUrgentDayType: InspectionDeadlineDayType.BUSINESS, + }; + } + + private async calculateDeadline( + manager: EntityManager, + baseAt: Date, + days: number, + dayType: InspectionDeadlineDayType, + ): Promise { + if (dayType === InspectionDeadlineDayType.CALENDAR) { + const [row] = await manager.query(` + SELECT ( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + $2::integer)::timestamp + + time '23:59:59' + ) AT TIME ZONE 'America/Argentina/Mendoza' AS due_at + `, [baseAt, days]) as Array<{ due_at: Date }>; + return row.due_at; + } + const [row] = await manager.query(` + WITH candidates AS ( + SELECT + day::date AS day, + COALESCE(override.is_business_day, EXTRACT(ISODOW FROM day)::integer BETWEEN 1 AND 5) AS business + FROM generate_series( + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 1)::timestamp, + (($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::date + 730)::timestamp, + interval '1 day' + ) day + LEFT JOIN inspection_business_calendar_days override ON override.date=day::date + ), ranked AS ( + SELECT day,ROW_NUMBER() OVER (ORDER BY day) AS position + FROM candidates + WHERE business=true + ) + SELECT ((day::timestamp + time '23:59:59') AT TIME ZONE 'America/Argentina/Mendoza') AS due_at + FROM ranked + WHERE position=$2 + LIMIT 1 + `, [baseAt, days]) as Array<{ due_at: Date }>; + if (!row?.due_at) throw new InternalServerErrorException('No se pudo calcular el vencimiento'); + return row.due_at; + } + + private async loadView(manager: EntityManager, actId: string): Promise { + const [context] = await manager.query(` + SELECT + act.id,act.code,act.status,act.visit_id AS "visitId", + act.urgency, + act.deadline_days AS "deadlineDays", + act.deadline_day_type AS "deadlineDayType", + act.deadline_basis AS "deadlineBasis", + act.deadline_base_at AS "deadlineBaseAt", + act.deadline_at AS "deadlineAt", + act.locked_at AS "lockedAt", + act.locked_sha256 AS "lockedSha256", + act.sealed_at AS "sealedAt", act.current_version AS "currentVersion", - act.closed_at AS "closedAt", - act.closed_by AS "closedBy", + act.closed_at AS "closedAt",act.closed_by AS "closedBy", act.closure_sha256 AS "closureSha256", - visit.code AS "visitCode", - visit.status AS "visitStatus", + visit.code AS "visitCode",visit.status AS "visitStatus", visit.actual_closed_at AS "visitActualClosedAt" FROM inspection_acts act - INNER JOIN inspection_visits visit ON visit.id = act.visit_id - WHERE act.id = $1 - `, [actId])) as Array<{ - id: string; - code: string; - status: InspectionActStatus; - visitId: string; - currentVersion: number; - closedAt: Date | null; - closedBy: string | null; - closureSha256: string | null; - visitCode: string; - visitStatus: InspectionVisitStatus; - visitActualClosedAt: Date | null; + JOIN inspection_visits visit ON visit.id=act.visit_id + WHERE act.id=$1 + `, [actId]) as Array<{ + id: string; code: string; status: InspectionActStatus; visitId: string; + urgency: InspectionActUrgency; deadlineDays: number | null; + deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null; + deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null; + lockedSha256: string | null; sealedAt: Date | null; currentVersion: number; + closedAt: Date | null; closedBy: string | null; closureSha256: string | null; + visitCode: string; visitStatus: InspectionVisitStatus; visitActualClosedAt: Date | null; }>; if (!context) throw actNotFound(); const responsible = await this.loadResponsible(manager, actId); @@ -1169,6 +1050,15 @@ export class InspectionClosingService { code: context.code, status: context.status, visitId: context.visitId, + urgency: context.urgency, + deadlineDays: context.deadlineDays, + deadlineDayType: context.deadlineDayType, + deadlineBasis: context.deadlineBasis, + deadlineBaseAt: context.deadlineBaseAt, + deadlineAt: context.deadlineAt, + lockedAt: context.lockedAt, + lockedSha256: context.lockedSha256, + sealedAt: context.sealedAt, currentVersion: Number(context.currentVersion), closedAt: context.closedAt, closedBy: context.closedBy, @@ -1191,8 +1081,7 @@ export class InspectionClosingService { serverClosedAt: closure.serverClosedAt, uploadMode: closure.uploadMode, closedBy: closure.closedBy, - isCurrent: context.status === InspectionActStatus.READY - || context.status === InspectionActStatus.CLOSED, + isCurrent: [InspectionActStatus.LOCKED, InspectionActStatus.SEALED].includes(context.status), } : null, signatures, consents: { @@ -1204,23 +1093,13 @@ export class InspectionClosingService { } private async loadResponsible(manager: EntityManager, actId: string): Promise { - const [row] = (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 ResponsibleView[]; + const [row] = 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 ResponsibleView[]; return row ?? null; } @@ -1229,30 +1108,22 @@ export class InspectionClosingService { if (!responsible) { throw new ConflictException({ code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED', - message: 'Debe identificarse al responsable presente o documentar su ausencia', + message: 'Debe identificarse al responsable o documentar su ausencia antes de finalizar el acta', }); } return responsible; } private async loadClosure(manager: EntityManager, actId: string): Promise { - const [row] = (await manager.query(` - SELECT - act_id AS "actId", - schema_version AS "schemaVersion", - prepared_snapshot AS "preparedSnapshot", - prepared_sha256 AS "preparedSha256", - prepared_at AS "preparedAt", - prepared_by AS "preparedBy", - final_snapshot AS "finalSnapshot", - final_sha256 AS "finalSha256", - device_closed_at AS "deviceClosedAt", - server_closed_at AS "serverClosedAt", - upload_mode AS "uploadMode", - closed_by AS "closedBy" - FROM inspection_act_closures - WHERE act_id = $1 - `, [actId])) as ClosureRecord[]; + const [row] = await manager.query(` + SELECT act_id AS "actId",schema_version AS "schemaVersion", + prepared_snapshot AS "preparedSnapshot",prepared_sha256 AS "preparedSha256", + prepared_at AS "preparedAt",prepared_by AS "preparedBy", + final_snapshot AS "finalSnapshot",final_sha256 AS "finalSha256", + device_closed_at AS "deviceClosedAt",server_closed_at AS "serverClosedAt", + upload_mode AS "uploadMode",closed_by AS "closedBy" + FROM inspection_act_closures WHERE act_id=$1 + `, [actId]) as ClosureRecord[]; return row ?? null; } @@ -1260,8 +1131,8 @@ export class InspectionClosingService { const closure = await this.loadClosure(manager, actId); if (!closure) { throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_PREPARED', - message: 'El acta todavía no fue preparada para firmas', + code: 'INSPECTION_ACT_NOT_LOCKED', + message: 'El acta todavía no fue finalizada y bloqueada', }); } return closure; @@ -1269,96 +1140,61 @@ export class InspectionClosingService { private signatureSelect(): string { return ` - SELECT - signature.id, - signature.act_id AS "actId", - signature.signer_type AS "signerType", - signature.signer_user_id AS "signerUserId", - signature.signer_name AS "signerName", - signature.document_type AS "documentType", - signature.document_number AS "documentNumber", - signature.position, - signature.status, - signature.reason, + SELECT signature.id,signature.act_id AS "actId",signature.signer_type AS "signerType", + signature.signer_user_id AS "signerUserId",signature.signer_name AS "signerName", + signature.document_type AS "documentType",signature.document_number AS "documentNumber", + signature.position,signature.status,signature.reason, signature.company_manifestation AS "companyManifestation", signature.company_statement AS "companyStatement", - signature.original_name AS "originalName", - signature.stored_name AS "storedName", - signature.mime_type AS "mimeType", - signature.size_bytes::integer AS "sizeBytes", - signature.image_sha256 AS "imageSha256", - signature.consent_text AS "consentText", - signature.consent_version AS "consentVersion", - signature.consent_accepted_at AS "consentAcceptedAt", - signature.client_signed_at AS "clientSignedAt", - signature.signed_at AS "signedAt", - signature.latitude::double precision AS latitude, - signature.longitude::double precision AS longitude, - signature.accuracy_m::double precision AS "accuracyM", - signature.device_label AS "deviceLabel", - signature.source, - signature.prepared_sha256 AS "preparedSha256", + signature.original_name AS "originalName",signature.stored_name AS "storedName", + signature.mime_type AS "mimeType",signature.size_bytes::integer AS "sizeBytes", + signature.image_sha256 AS "imageSha256",signature.consent_text AS "consentText", + signature.consent_version AS "consentVersion",signature.consent_accepted_at AS "consentAcceptedAt", + signature.client_signed_at AS "clientSignedAt",signature.signed_at AS "signedAt", + signature.latitude::double precision AS latitude,signature.longitude::double precision AS longitude, + signature.accuracy_m::double precision AS "accuracyM",signature.device_label AS "deviceLabel", + signature.source,signature.prepared_sha256 AS "preparedSha256", signature.signature_payload_sha256 AS "signaturePayloadSha256", - signature.uploaded_by AS "uploadedBy", - uploader.username AS "uploadedByUsername", + signature.uploaded_by AS "uploadedBy",uploader.username AS "uploadedByUsername", signature.created_at AS "createdAt" FROM inspection_act_signatures signature - INNER JOIN users uploader ON uploader.id = signature.uploaded_by + JOIN users uploader ON uploader.id=signature.uploaded_by `; } private async loadSignatures(manager: EntityManager, actId: string): Promise { const rows = await manager.query(` - ${this.signatureSelect()} - WHERE signature.act_id = $1 - ORDER BY signature.signer_type, signature.created_at, signature.id + ${this.signatureSelect()} WHERE signature.act_id=$1 + ORDER BY signature.signer_type,signature.created_at,signature.id `, [actId]) as StoredSignature[]; return rows.map(({ storedName: _storedName, originalName: _originalName, ...row }) => row); } private async loadStoredSignature(manager: EntityManager, id: string): Promise { - const [row] = await manager.query(` - ${this.signatureSelect()} - WHERE signature.id = $1 - `, [id]) as StoredSignature[]; + const [row] = await manager.query(`${this.signatureSelect()} WHERE signature.id=$1`, [id]) as StoredSignature[]; if (!row) throw signatureNotFound(); return row; } private async signatureCount(manager: EntityManager, actId: string): Promise { - const [row] = (await manager.query(` - SELECT COUNT(*)::integer AS total - FROM inspection_act_signatures - WHERE act_id = $1 - `, [actId])) as Array<{ total: number }>; + const [row] = await manager.query(`SELECT COUNT(*)::integer AS total FROM inspection_act_signatures WHERE act_id=$1`, [actId]) as Array<{ total: number }>; return Number(row?.total ?? 0); } private async hasCompanyOutcome(manager: EntityManager, actId: string): Promise { const rows = await manager.query(` - SELECT 1 - FROM inspection_act_signatures - WHERE act_id = $1 AND signer_type = 'COMPANY_RESPONSIBLE' - LIMIT 1 + SELECT 1 FROM inspection_act_signatures + WHERE act_id=$1 AND signer_type='COMPANY_RESPONSIBLE' LIMIT 1 `, [actId]) as unknown[]; return rows.length > 0; } - private async lockContext( - manager: EntityManager, - actId: string, - ): Promise<{ act: InspectionAct; visit: InspectionVisit }> { + private async lockContext(manager: EntityManager, actId: string): Promise<{ act: InspectionAct; visit: InspectionVisit }> { const act = await manager.getRepository(InspectionAct) - .createQueryBuilder('act') - .where('act.id = :actId', { actId }) - .setLock('pessimistic_write') - .getOne(); + .createQueryBuilder('act').where('act.id=:actId', { actId }).setLock('pessimistic_write').getOne(); if (!act) throw actNotFound(); const visit = await manager.getRepository(InspectionVisit) - .createQueryBuilder('visit') - .where('visit.id = :visitId', { visitId: act.visitId }) - .setLock('pessimistic_write') - .getOne(); + .createQueryBuilder('visit').where('visit.id=:visitId', { visitId: act.visitId }).setLock('pessimistic_write').getOne(); if (!visit) throw actNotFound(); return { act, visit }; } @@ -1367,56 +1203,35 @@ export class InspectionClosingService { if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_ACT_NOT_EDITABLE', - message: 'El responsable sólo puede modificarse con el acta en borrador y la visita en curso', + message: 'El acta sólo puede editarse mientras está en borrador y la inspección está en curso', }); } } - private assertReadyInProgress(act: InspectionAct, visit: InspectionVisit): void { - if (act.status !== InspectionActStatus.READY || visit.status !== InspectionVisitStatus.IN_PROGRESS) { + private assertLockedForManifestation(act: InspectionAct): void { + if (act.status !== InspectionActStatus.LOCKED) { throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY', - message: 'El acta debe estar preparada y la visita en curso', - }); - } - } - - private assertReadyForCompanyOutcome(act: InspectionAct, visit: InspectionVisit): void { - if (act.status !== InspectionActStatus.READY - || ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) { - throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY_FOR_COMPANY', - message: 'El acta debe estar preparada; la firma de empresa puede completarse durante la inspección o después de su cierre', - }); - } - } - - private assertReadyForFinalClosure(act: InspectionAct, visit: InspectionVisit): void { - if (act.status !== InspectionActStatus.READY - || ![InspectionVisitStatus.IN_PROGRESS, InspectionVisitStatus.CLOSED].includes(visit.status)) { - throw new ConflictException({ - code: 'INSPECTION_ACT_NOT_READY', - message: 'El acta debe estar preparada para completar su cierre definitivo', + code: 'INSPECTION_ACT_NOT_LOCKED', + message: 'El acta debe estar finalizada y bloqueada antes de firmarse o sellarse', }); } } private async assertActorAssigned( manager: EntityManager, - visitId: string, + inspectionId: string, principal: AuthPrincipal, allowManager: boolean, ): Promise { if (allowManager && 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) { + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true + `, [inspectionId, principal.userId]) as unknown[]; + if (!rows.length) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', - message: 'Sólo un integrante asignado a la visita puede realizar esta firma', + message: 'Sólo un inspector asignado puede realizar esta operación', }); } }