363 lines
14 KiB
TypeScript
363 lines
14 KiB
TypeScript
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);
|
|
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: InspectionVisitStatus.DRAFT },
|
|
afterData: { status: InspectionVisitStatus.PLANNED },
|
|
metadata: { transition: 'PLAN' },
|
|
}, manager);
|
|
});
|
|
return this.visits.getById(id);
|
|
}
|
|
|
|
async unplan(
|
|
id: string,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
) {
|
|
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_FOR_UNPLAN',
|
|
message: 'Sólo una Inspección planificada puede volver a borrador',
|
|
});
|
|
}
|
|
const [startedAct] = await manager.query(`
|
|
SELECT 1
|
|
FROM inspection_acts act
|
|
WHERE act.visit_id=$1
|
|
AND act.status NOT IN ('DRAFT','CANCELLED')
|
|
LIMIT 1
|
|
`, [id]) as unknown[];
|
|
if (startedAct) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_UNPLAN_ACT_ALREADY_ADVANCED',
|
|
message: 'La Inspección ya tiene un Acta avanzada y no puede volver a borrador',
|
|
});
|
|
}
|
|
visit.status = InspectionVisitStatus.DRAFT;
|
|
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.PLANNED },
|
|
afterData: { status: InspectionVisitStatus.DRAFT },
|
|
metadata: { transition: 'UNPLAN', explicitAction: true },
|
|
}, 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 })),
|
|
});
|
|
}
|
|
await this.assertVerificationResultsComplete(manager, id);
|
|
|
|
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,
|
|
allVerificationResultsRecorded: 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 assertVerificationResultsComplete(manager: EntityManager, inspectionId: string): Promise<void> {
|
|
const [row] = await manager.query(`
|
|
SELECT
|
|
COUNT(*)::integer AS total,
|
|
COUNT(*) FILTER (WHERE outcome IS NOT NULL)::integer AS completed
|
|
FROM inspection_finding_verification_visits
|
|
WHERE visit_id=$1
|
|
`, [inspectionId]) as Array<{ total: number; completed: number }>;
|
|
if (Number(row?.total ?? 0) > 0 && Number(row.completed) !== Number(row.total)) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED',
|
|
message: 'Registrá el resultado de todas las verificaciones antes de cerrar la Inspección',
|
|
});
|
|
}
|
|
}
|
|
|
|
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',
|
|
});
|
|
}
|
|
if (!visit.checklistGeneratedAt || visit.checklistGeneration < 1) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_CHECKLIST_REQUIRED',
|
|
message: 'Generá el checklist automático antes de confirmar la planificación',
|
|
});
|
|
}
|
|
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',
|
|
});
|
|
}
|
|
}
|
|
}
|