F4: implement explicit inspection lifecycle

This commit is contained in:
2026-09-07 21:19:12 -03:00
parent 83ab2d4060
commit 577e267f49
@@ -0,0 +1,297 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } 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, InspectionVisit, InspectionVisitStatus } from '../database/entities';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import type { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto';
import type { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto';
import { InspectionVisitsService } from './inspection-visits.service';
interface PendingActRow {
id: string;
code: string;
status: string;
}
@Injectable()
export class InspectionVisitLifecycleService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly visits: InspectionVisitsService,
) {}
async plan(
id: string,
principal: AuthPrincipal,
request: RequestWithContext,
) {
await this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
if (visit.status !== InspectionVisitStatus.DRAFT) {
throw new ConflictException({
code: 'INSPECTION_NOT_DRAFT',
message: 'Sólo una Inspección en borrador puede planificarse',
});
}
await this.validatePlanningContext(manager, visit);
const before = visit.status;
visit.status = InspectionVisitStatus.PLANNED;
visit.cancellationReason = null;
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { status: before },
afterData: { status: InspectionVisitStatus.PLANNED },
metadata: { transition: 'PLAN' },
}, manager);
});
return this.visits.getById(id);
}
async start(
id: string,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
await this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
if (visit.status !== InspectionVisitStatus.PLANNED) {
throw new ConflictException({
code: 'INSPECTION_NOT_PLANNED',
message: 'La Inspección debe estar planificada antes de iniciarse',
});
}
await this.validatePlanningContext(manager, visit);
await this.assertActorAssigned(manager, visit, principal);
const startedAt = new Date();
visit.status = InspectionVisitStatus.IN_PROGRESS;
visit.actualStartedAt = startedAt;
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_STARTED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { status: InspectionVisitStatus.PLANNED, actualStartedAt: null },
afterData: { status: InspectionVisitStatus.IN_PROGRESS, actualStartedAt: startedAt },
metadata: { transition: 'START', source: 'ANDROID' },
}, manager);
});
return this.visits.getById(id);
}
async close(
id: string,
dto: CloseInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
await this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
if (visit.status !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'INSPECTION_NOT_IN_PROGRESS',
message: 'La Inspección debe estar en curso para cerrarse',
});
}
await this.assertActorAssigned(manager, visit, principal);
const acts = await manager.query(`
SELECT id,code,status
FROM inspection_acts
WHERE visit_id=$1 AND status<>'CANCELLED'
ORDER BY occurred_at,created_at,id
FOR UPDATE
`, [id]) as PendingActRow[];
if (acts.length === 0) {
throw new ConflictException({
code: 'INSPECTION_REQUIRES_ACT',
message: 'La Inspección debe tener al menos un Acta antes de cerrarse',
});
}
const pending = acts.filter((act) => act.status !== 'SEALED');
if (pending.length > 0) {
throw new ConflictException({
code: 'INSPECTION_ACTS_NOT_SEALED',
message: 'Todas las Actas deben estar SELLADAS antes de cerrar la Inspección',
pendingActs: pending.map((act) => ({ id: act.id, code: act.code, status: act.status })),
});
}
const serverClosedAt = new Date();
const clientClosedAt = new Date(dto.clientClosedAt);
if (!Number.isFinite(clientClosedAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_CLOSE_TIME_INVALID',
message: 'La fecha de cierre informada por el dispositivo no es válida',
});
}
if (visit.actualStartedAt && clientClosedAt.getTime() < visit.actualStartedAt.getTime()) {
throw new BadRequestException({
code: 'INSPECTION_CLOSE_BEFORE_START',
message: 'La fecha de cierre no puede ser anterior al inicio de la Inspección',
});
}
if (clientClosedAt.getTime() > serverClosedAt.getTime() + 24 * 60 * 60 * 1000) {
throw new BadRequestException({
code: 'INSPECTION_CLOSE_TIME_IN_FUTURE',
message: 'La fecha del 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);
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(),
sealedActs: acts.length,
},
metadata: {
transition: 'CLOSE',
source: 'ANDROID',
allActsSealed: true,
},
}, manager);
});
return this.visits.getById(id);
}
async cancel(
id: string,
dto: CancelInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
await this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
if (![InspectionVisitStatus.DRAFT, InspectionVisitStatus.PLANNED, InspectionVisitStatus.IN_PROGRESS].includes(visit.status)) {
throw new ConflictException({
code: 'INSPECTION_CANNOT_CANCEL',
message: 'La Inspección ya no puede cancelarse en su estado actual',
});
}
const [sealedAct] = await manager.query(`
SELECT 1 FROM inspection_acts
WHERE visit_id=$1 AND status='SEALED'
LIMIT 1
`, [id]) as unknown[];
if (sealedAct) {
throw new ConflictException({
code: 'INSPECTION_WITH_SEALED_ACT_CANNOT_CANCEL',
message: 'Una Inspección con Actas SELLADAS no puede cancelarse',
});
}
const before = visit.status;
visit.status = InspectionVisitStatus.CANCELLED;
visit.cancellationReason = dto.reason;
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_STATUS_CHANGED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { status: before },
afterData: { status: InspectionVisitStatus.CANCELLED, cancellationReason: dto.reason },
metadata: { transition: 'CANCEL' },
}, manager);
});
return this.visits.getById(id);
}
private async lockVisit(manager: EntityManager, id: string): Promise<InspectionVisit> {
const visit = await manager.getRepository(InspectionVisit)
.createQueryBuilder('visit')
.where('visit.id=:id', { id })
.setLock('pessimistic_write')
.getOne();
if (!visit) {
throw new NotFoundException({ code: 'INSPECTION_NOT_FOUND', message: 'Inspección no encontrada' });
}
return visit;
}
private async validatePlanningContext(manager: EntityManager, visit: InspectionVisit): Promise<void> {
if (!visit.operationalAreaId || !visit.operatorCompanyId || !visit.leadInspectorUserId || !visit.plannedStartAt) {
throw new ConflictException({
code: 'INSPECTION_PLANNING_INCOMPLETE',
message: 'Definí Área/Yacimiento, Operadora, Inspector responsable y fecha de inicio antes de planificar',
});
}
const [relation] = await manager.query(`
SELECT 1
FROM area_company_relations
WHERE area_id=$1 AND company_id=$2
AND relation_role='OPERATOR'
AND valid_from<=CURRENT_TIMESTAMP
AND (valid_until IS NULL OR valid_until>CURRENT_TIMESTAMP)
LIMIT 1
`, [visit.operationalAreaId, visit.operatorCompanyId]) as unknown[];
if (!relation) {
throw new ConflictException({
code: 'INSPECTION_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no tiene una relación operativa vigente con el Área/Yacimiento',
});
}
const [inspector] = await manager.query(`
SELECT 1
FROM users user_account
JOIN user_roles user_role ON user_role.user_id=user_account.id
JOIN role_permissions role_permission ON role_permission.role_id=user_role.role_id
JOIN permissions permission ON permission.id=role_permission.permission_id
WHERE user_account.id=$1 AND user_account.status='ACTIVE'
AND permission.code='inspections.execute'
LIMIT 1
`, [visit.leadInspectorUserId]) as unknown[];
if (!inspector) {
throw new ConflictException({
code: 'INSPECTION_LEAD_INVALID',
message: 'El Inspector responsable no está activo o no puede ejecutar Inspecciones',
});
}
}
private async assertActorAssigned(
manager: EntityManager,
visit: InspectionVisit,
principal: AuthPrincipal,
): Promise<void> {
if (principal.permissions.includes('inspections.manage')) return;
const [member] = await manager.query(`
SELECT 1 FROM inspection_visit_members
WHERE visit_id=$1 AND user_id=$2 AND included=true
LIMIT 1
`, [visit.id, principal.userId]) as unknown[];
if (!member && visit.leadInspectorUserId !== principal.userId) {
throw new ForbiddenException({
code: 'INSPECTION_NOT_ASSIGNED',
message: 'La Inspección no está asignada al usuario actual',
});
}
}
}