refactor(f4): remove legacy inspection lifecycle and public title/end date
This commit is contained in:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user