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