F1.1 · Inspección multi-Acta y firma diferida

Implementa el nuevo núcleo operativo: múltiples Actas por Inspección, un Informe por Acta, cierre de campo independiente y firma de empresa diferida con conformidad/disidencia.
This commit is contained in:
2026-09-05 14:46:26 -03:00
committed by GitHub
parent 8e2d0dfe19
commit cd24736df8
34 changed files with 452 additions and 85 deletions
@@ -19,6 +19,7 @@ import {
InspectionVisitStatus,
} from '../database/entities';
import type { ChangeInspectionVisitStatusDto } from './dto/change-inspection-visit-status.dto';
import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import type { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
@@ -810,6 +811,107 @@ export class InspectionVisitsService {
});
}
async close(
id: string,
dto: CloseInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
assertMobileInspector(principal);
return this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
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 memberIds = await this.activeMemberIds(manager, id);
if (!memberIds.includes(principal.userId)) {
throw new ForbiddenException({
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
message: 'La inspección no está asignada al usuario actual',
});
}
}
const [acts] = (await manager.query(`
SELECT
COUNT(*) FILTER (WHERE act.status = 'DRAFT')::integer AS drafts,
COUNT(*) FILTER (
WHERE act.status = 'READY'
AND NOT EXISTS (
SELECT 1 FROM inspection_act_signatures signature
WHERE signature.act_id = act.id
AND signature.signer_type = 'INSPECTOR'
AND signature.status = 'SIGNED'
)
)::integer AS "readyWithoutInspector",
COUNT(*) FILTER (
WHERE act.status = 'READY'
AND NOT EXISTS (
SELECT 1 FROM inspection_act_signatures signature
WHERE signature.act_id = act.id
AND signature.signer_type = 'COMPANY_RESPONSIBLE'
)
)::integer AS "pendingCompany"
FROM inspection_acts act
WHERE act.visit_id = $1
AND act.status <> 'CANCELLED'
`, [id])) as Array<{ drafts: number; readyWithoutInspector: number; pendingCompany: number }>;
if (Number(acts?.drafts ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_DRAFT_ACTS_PENDING',
message: 'Prepará o cancelá las actas en borrador antes de cerrar la inspección',
});
}
if (Number(acts?.readyWithoutInspector ?? 0) > 0) {
throw new ConflictException({
code: 'INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING',
message: 'Toda acta preparada debe tener firma de inspector antes de cerrar la inspección',
});
}
const serverClosedAt = new Date();
const clientClosedAt = new Date(dto.clientClosedAt);
if (visit.actualStartedAt && clientClosedAt.getTime() < 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',
});
}
visit.status = InspectionVisitStatus.CLOSED;
visit.actualClosedAt = serverClosedAt;
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
const updated = await this.loadView(manager, id);
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(),
pendingCompanySignatures: Number(acts?.pendingCompany ?? 0),
},
metadata: {
closeSource: 'ANDROID',
actsMayCompleteCompanySignatureLater: true,
},
}, manager);
return updated;
});
}
private visitSelect(where: string): string {
return `
SELECT