refactor(f4): remove legacy inspection lifecycle and public title/end date
This commit is contained in:
@@ -1,7 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
BadRequestException,
|
BadRequestException,
|
||||||
ConflictException,
|
ConflictException,
|
||||||
ForbiddenException,
|
|
||||||
Injectable,
|
Injectable,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
@@ -18,14 +17,11 @@ import {
|
|||||||
InspectionVisitAssetPlanningSource,
|
InspectionVisitAssetPlanningSource,
|
||||||
InspectionVisitStatus,
|
InspectionVisitStatus,
|
||||||
} from '../database/entities';
|
} 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 { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto';
|
||||||
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
import type { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto';
|
||||||
import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
|
import type { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto';
|
||||||
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
import type { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto';
|
||||||
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
import type { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto';
|
||||||
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
|
||||||
import { nextInspectionVisitCode } from './inspection-visit-code';
|
import { nextInspectionVisitCode } from './inspection-visit-code';
|
||||||
|
|
||||||
export interface InspectionPerson {
|
export interface InspectionPerson {
|
||||||
@@ -91,7 +87,6 @@ export interface InspectionVisitChecklistSummary {
|
|||||||
export interface InspectionVisitListItem {
|
export interface InspectionVisitListItem {
|
||||||
id: string;
|
id: string;
|
||||||
code: string;
|
code: string;
|
||||||
title: string;
|
|
||||||
objective: string | null;
|
objective: string | null;
|
||||||
status: InspectionVisitStatus;
|
status: InspectionVisitStatus;
|
||||||
scopeAsset: InspectionAssetSummary | null;
|
scopeAsset: InspectionAssetSummary | null;
|
||||||
@@ -99,7 +94,6 @@ export interface InspectionVisitListItem {
|
|||||||
operatorCompany: InspectionPlanningContextAsset | null;
|
operatorCompany: InspectionPlanningContextAsset | null;
|
||||||
leadInspector: InspectionPerson | null;
|
leadInspector: InspectionPerson | null;
|
||||||
plannedStartAt: Date | null;
|
plannedStartAt: Date | null;
|
||||||
plannedEndAt: Date | null;
|
|
||||||
actualStartedAt: Date | null;
|
actualStartedAt: Date | null;
|
||||||
actualClosedAt: Date | null;
|
actualClosedAt: Date | null;
|
||||||
instructions: string | null;
|
instructions: string | null;
|
||||||
@@ -165,7 +159,7 @@ export class InspectionVisitsService {
|
|||||||
};
|
};
|
||||||
if (query.search?.trim()) {
|
if (query.search?.trim()) {
|
||||||
const search = add(`%${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.status) conditions.push(`visit.status = ${add(query.status)}`);
|
||||||
if (query.companyId) {
|
if (query.companyId) {
|
||||||
@@ -324,6 +318,8 @@ export class InspectionVisitsService {
|
|||||||
const code = await nextInspectionVisitCode(manager, plannedStartAt);
|
const code = await nextInspectionVisitCode(manager, plannedStartAt);
|
||||||
const visit = manager.getRepository(InspectionVisit).create({
|
const visit = manager.getRepository(InspectionVisit).create({
|
||||||
code,
|
code,
|
||||||
|
// Compatibilidad de persistencia temporal: estas dos columnas legacy se
|
||||||
|
// conservan hasta eliminar sus últimas lecturas históricas del repositorio.
|
||||||
title: code,
|
title: code,
|
||||||
objective: null,
|
objective: null,
|
||||||
status: InspectionVisitStatus.DRAFT,
|
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 {
|
private visitSelect(where: string): string {
|
||||||
return `
|
return `
|
||||||
SELECT
|
SELECT
|
||||||
visit.id,
|
visit.id,
|
||||||
visit.code,
|
visit.code,
|
||||||
visit.title,
|
|
||||||
visit.objective,
|
visit.objective,
|
||||||
visit.status,
|
visit.status,
|
||||||
CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
||||||
@@ -943,7 +741,6 @@ export class InspectionVisitsService {
|
|||||||
'lastName', lead.last_name
|
'lastName', lead.last_name
|
||||||
) END AS "leadInspector",
|
) END AS "leadInspector",
|
||||||
visit.planned_start_at AS "plannedStartAt",
|
visit.planned_start_at AS "plannedStartAt",
|
||||||
visit.planned_end_at AS "plannedEndAt",
|
|
||||||
visit.actual_started_at AS "actualStartedAt",
|
visit.actual_started_at AS "actualStartedAt",
|
||||||
visit.actual_closed_at AS "actualClosedAt",
|
visit.actual_closed_at AS "actualClosedAt",
|
||||||
visit.instructions,
|
visit.instructions,
|
||||||
@@ -1615,7 +1412,6 @@ export class InspectionVisitsService {
|
|||||||
private auditView(visit: InspectionVisitView): Record<string, unknown> {
|
private auditView(visit: InspectionVisitView): Record<string, unknown> {
|
||||||
return {
|
return {
|
||||||
code: visit.code,
|
code: visit.code,
|
||||||
title: visit.title,
|
|
||||||
objective: visit.objective,
|
objective: visit.objective,
|
||||||
status: visit.status,
|
status: visit.status,
|
||||||
scopeAssetId: visit.scopeAsset?.id ?? null,
|
scopeAssetId: visit.scopeAsset?.id ?? null,
|
||||||
@@ -1623,7 +1419,6 @@ export class InspectionVisitsService {
|
|||||||
operatorCompanyId: visit.operatorCompany?.id ?? null,
|
operatorCompanyId: visit.operatorCompany?.id ?? null,
|
||||||
leadInspectorUserId: visit.leadInspector?.id ?? null,
|
leadInspectorUserId: visit.leadInspector?.id ?? null,
|
||||||
plannedStartAt: visit.plannedStartAt,
|
plannedStartAt: visit.plannedStartAt,
|
||||||
plannedEndAt: visit.plannedEndAt,
|
|
||||||
actualStartedAt: visit.actualStartedAt,
|
actualStartedAt: visit.actualStartedAt,
|
||||||
actualClosedAt: visit.actualClosedAt,
|
actualClosedAt: visit.actualClosedAt,
|
||||||
instructions: visit.instructions,
|
instructions: visit.instructions,
|
||||||
|
|||||||
Reference in New Issue
Block a user