F4.8 · cierre autoritativo de Inspección

This commit is contained in:
2026-09-07 20:12:48 -03:00
parent b80f83ed5f
commit d0cc7c6f9d
@@ -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);
}
}