F4.8 · cierre autoritativo de Inspección
This commit is contained in:
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user