refactor(f4): remove legacy inspection lifecycle and public title/end date

This commit is contained in:
2026-09-08 07:39:38 -03:00
parent bd767b7e32
commit 3f0a1e5441
@@ -1,7 +1,6 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
@@ -18,14 +17,11 @@ import {
InspectionVisitAssetPlanningSource,
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';
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { nextInspectionVisitCode } from './inspection-visit-code';
export interface InspectionPerson {
@@ -91,7 +87,6 @@ export interface InspectionVisitChecklistSummary {
export interface InspectionVisitListItem {
id: string;
code: string;
title: string;
objective: string | null;
status: InspectionVisitStatus;
scopeAsset: InspectionAssetSummary | null;
@@ -99,7 +94,6 @@ export interface InspectionVisitListItem {
operatorCompany: InspectionPlanningContextAsset | null;
leadInspector: InspectionPerson | null;
plannedStartAt: Date | null;
plannedEndAt: Date | null;
actualStartedAt: Date | null;
actualClosedAt: Date | null;
instructions: string | null;
@@ -165,7 +159,7 @@ export class InspectionVisitsService {
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`(visit.code ILIKE ${search} OR visit.title ILIKE ${search})`);
conditions.push(`visit.code ILIKE ${search}`);
}
if (query.status) conditions.push(`visit.status = ${add(query.status)}`);
if (query.companyId) {
@@ -324,6 +318,8 @@ export class InspectionVisitsService {
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const visit = manager.getRepository(InspectionVisit).create({
code,
// Compatibilidad de persistencia temporal: estas dos columnas legacy se
// conservan hasta eliminar sus últimas lecturas históricas del repositorio.
title: code,
objective: null,
status: InspectionVisitStatus.DRAFT,
@@ -715,209 +711,11 @@ export class InspectionVisitsService {
});
}
async changeStatus(
id: string,
dto: ChangeInspectionVisitStatusDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
return this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
if (visit.status === dto.status) return this.loadView(manager, id);
const cancellation = dto.status === InspectionVisitStatus.CANCELLED;
const reversiblePlanning =
(visit.status === InspectionVisitStatus.DRAFT && dto.status === InspectionVisitStatus.PLANNED) ||
(visit.status === InspectionVisitStatus.PLANNED && dto.status === InspectionVisitStatus.DRAFT);
const cancellable = cancellation && [
InspectionVisitStatus.DRAFT,
InspectionVisitStatus.PLANNED,
InspectionVisitStatus.IN_PROGRESS,
].includes(visit.status);
if (!reversiblePlanning && !cancellable) {
throw new ConflictException({
code: 'INSPECTION_VISIT_INVALID_TRANSITION',
message: 'La transición solicitada no está permitida en esta fase',
});
}
if (dto.status === InspectionVisitStatus.PLANNED) {
await this.validatePlan(manager, visit);
}
const reason = dto.reason?.trim() || null;
if (cancellation && (!reason || reason.length < 10)) {
throw new BadRequestException({
code: 'INSPECTION_CANCELLATION_REASON_REQUIRED',
message: 'Indicá un motivo de cancelación de al menos 10 caracteres',
});
}
const beforeStatus = visit.status;
visit.status = dto.status;
visit.cancellationReason = cancellation ? reason : null;
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: beforeStatus },
afterData: { status: dto.status, cancellationReason: visit.cancellationReason },
}, manager);
return updated;
});
}
async start(
id: string,
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.PLANNED) {
throw new ConflictException({
code: 'INSPECTION_VISIT_NOT_PLANNED',
message: 'La visita debe estar planificada antes de iniciarse',
});
}
await this.validatePlan(manager, visit);
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 visita no está asignada al usuario actual',
});
}
}
visit.status = InspectionVisitStatus.IN_PROGRESS;
visit.actualStartedAt = new Date();
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_STARTED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { status: InspectionVisitStatus.PLANNED, actualStartedAt: null },
afterData: {
status: InspectionVisitStatus.IN_PROGRESS,
actualStartedAt: visit.actualStartedAt,
},
}, manager);
return updated;
});
}
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
visit.id,
visit.code,
visit.title,
visit.objective,
visit.status,
CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
@@ -943,7 +741,6 @@ export class InspectionVisitsService {
'lastName', lead.last_name
) END AS "leadInspector",
visit.planned_start_at AS "plannedStartAt",
visit.planned_end_at AS "plannedEndAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
visit.instructions,
@@ -1615,7 +1412,6 @@ export class InspectionVisitsService {
private auditView(visit: InspectionVisitView): Record<string, unknown> {
return {
code: visit.code,
title: visit.title,
objective: visit.objective,
status: visit.status,
scopeAssetId: visit.scopeAsset?.id ?? null,
@@ -1623,7 +1419,6 @@ export class InspectionVisitsService {
operatorCompanyId: visit.operatorCompany?.id ?? null,
leadInspectorUserId: visit.leadInspector?.id ?? null,
plannedStartAt: visit.plannedStartAt,
plannedEndAt: visit.plannedEndAt,
actualStartedAt: visit.actualStartedAt,
actualClosedAt: visit.actualClosedAt,
instructions: visit.instructions,