diff --git a/api-v3/src/inspection-visits/inspection-visit-closure.service.ts b/api-v3/src/inspection-visits/inspection-visit-closure.service.ts new file mode 100644 index 0000000..42083a4 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-visit-closure.service.ts @@ -0,0 +1,186 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource } 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, InspectionVisitStatus } from '../database/entities'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; +import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; +import { InspectionVisitsService } from './inspection-visits.service'; + +@Injectable() +export class InspectionVisitClosureService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly visits: InspectionVisitsService, + ) {} + + async close( + id: string, + dto: CloseInspectionVisitDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + assertMobileInspector(principal); + await this.dataSource.transaction(async (manager) => { + const [visit] = await manager.query(` + SELECT + id, + status, + actual_started_at AS "actualStartedAt" + FROM inspection_visits + WHERE id = $1 + FOR UPDATE + `, [id]) as Array<{ + id: string; + status: InspectionVisitStatus; + actualStartedAt: Date | null; + }>; + if (!visit) { + throw new NotFoundException({ + code: 'INSPECTION_VISIT_NOT_FOUND', + message: 'Inspección no encontrada', + }); + } + if (visit.status !== InspectionVisitStatus.IN_PROGRESS) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_NOT_IN_PROGRESS', + message: 'La Inspección debe estar en curso para cerrarse', + }); + } + if (!principal.permissions.includes('inspections.manage')) { + const [member] = await manager.query(` + SELECT 1 AS found + FROM inspection_visit_members + WHERE visit_id = $1 + AND user_id = $2 + AND included = true + LIMIT 1 + `, [id, principal.userId]) as Array<{ found: number }>; + if (!member) { + throw new ForbiddenException({ + code: 'INSPECTION_VISIT_NOT_ASSIGNED', + message: 'La Inspección no está asignada al usuario actual', + }); + } + } + + const [actState] = await manager.query(` + SELECT + COUNT(*) FILTER (WHERE act.status <> 'CANCELLED')::integer AS total, + COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts, + COUNT(*) FILTER (WHERE act.status = 'READY')::integer AS "pendingSignature", + COUNT(*) FILTER ( + WHERE act.status NOT IN ('CLOSED', 'CANCELLED') + )::integer AS "notSealed", + COUNT(*) FILTER ( + WHERE act.status = 'CLOSED' + AND ( + act.closure_sha256 IS NULL + OR NOT EXISTS ( + SELECT 1 + FROM inspection_act_signatures signature + WHERE signature.act_id = act.id + AND signature.signer_type = 'INSPECTOR' + AND signature.status = 'SIGNED' + ) + OR 1 <> ( + SELECT COUNT(*) + FROM inspection_act_signatures signature + WHERE signature.act_id = act.id + AND signature.signer_type = 'COMPANY_RESPONSIBLE' + AND signature.status IN ('SIGNED', 'REFUSED', 'ABSENT') + ) + ) + )::integer AS "sealedInvalid" + FROM inspection_acts act + WHERE act.visit_id = $1 + `, [id]) as Array<{ + total: number; + drafts: number; + pendingSignature: number; + notSealed: number; + sealedInvalid: number; + }>; + if (Number(actState?.total ?? 0) < 1) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_ACT_REQUIRED', + message: 'La Inspección debe contener al menos un Acta antes de cerrarse', + }); + } + if (Number(actState?.drafts ?? 0) > 0) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING', + message: 'Finalizá o cancelá todas las Actas en borrador antes de cerrar la Inspección', + }); + } + if (Number(actState?.pendingSignature ?? 0) > 0) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_COMPANY_OUTCOME_PENDING', + message: 'No se puede cerrar la Inspección mientras exista un Acta pendiente de firma, disidencia, negativa o constancia de ausencia de la empresa', + }); + } + if (Number(actState?.notSealed ?? 0) > 0 || Number(actState?.sealedInvalid ?? 0) > 0) { + throw new ConflictException({ + code: 'INSPECTION_VISIT_ACTS_NOT_SEALED', + message: 'Todas las Actas deben estar selladas con su evidencia de firma antes de cerrar la Inspección', + }); + } + + const serverClosedAt = new Date(); + const clientClosedAt = new Date(dto.clientClosedAt); + if (!Number.isFinite(clientClosedAt.getTime())) { + throw new BadRequestException({ + code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME', + message: 'La fecha de cierre informada por el dispositivo no es válida', + }); + } + if (visit.actualStartedAt && clientClosedAt.getTime() < new Date(visit.actualStartedAt).getTime()) { + throw new BadRequestException({ + code: 'INSPECTION_VISIT_INVALID_CLOSE_TIME', + message: 'La fecha de cierre del dispositivo no puede ser anterior al inicio de la Inspección', + }); + } + if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) { + throw new BadRequestException({ + code: 'INSPECTION_VISIT_INVALID_DEVICE_TIME', + message: 'La fecha informada por el dispositivo no puede estar más de 24 horas en el futuro', + }); + } + + await manager.query(` + UPDATE inspection_visits + SET status = 'CLOSED', + actual_closed_at = $2, + updated_by = $3, + updated_at = $2 + WHERE id = $1 + `, [id, serverClosedAt, principal.userId]); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED, + entityType: 'inspection_visit', + entityId: id, + beforeData: { status: InspectionVisitStatus.IN_PROGRESS }, + afterData: { + status: InspectionVisitStatus.CLOSED, + clientClosedAt: clientClosedAt.toISOString(), + serverClosedAt: serverClosedAt.toISOString(), + allActsSealed: true, + }, + metadata: { + closeSource: 'ANDROID', + companyOutcomeRequiredBeforeClose: true, + }, + }, manager); + }); + return this.visits.getById(id); + } +}