F4: implement explicit inspection lifecycle
This commit is contained in:
@@ -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',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user