Files
dh-inspeccion-v2/api-v3/src/inspection-visits/inspection-visits.service.ts
T

1430 lines
54 KiB
TypeScript

import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import {
administrationAuditContext,
isUniqueViolation,
} from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
InspectionVisit,
InspectionVisitAssetPlanningSource,
InspectionVisitStatus,
} from '../database/entities';
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 { nextInspectionVisitCode } from './inspection-visit-code';
export interface InspectionPerson {
id: string;
username: string;
firstName: string;
lastName: string;
}
export interface InspectionAssetSummary {
id: string;
code: string;
name: string;
typeName: string;
}
export interface InspectionPlanningContextAsset {
id: string;
code: string;
name: string;
}
export interface InspectionPlannedAsset extends InspectionAssetSummary {
included: boolean;
planningSource: InspectionVisitAssetPlanningSource;
exclusionReason: string | null;
excludedAt: Date | null;
excludedBy: InspectionPerson | null;
}
export type InspectionChecklistItemKind =
| 'ANTECEDENT'
| 'COMPANY_OVERDUE'
| 'VERIFICATION_OVERDUE'
| 'UPCOMING_CONTROL';
export interface InspectionVisitChecklistItem {
id: string;
findingId: string;
findingCode: string;
findingTitle: string;
findingStatus: string;
severity: number | null;
itemKind: InspectionChecklistItemKind;
referenceOn: string | null;
asset: InspectionAssetSummary;
assetIncluded: boolean;
}
export interface InspectionVisitChecklistSummary {
generation: number;
generatedAt: Date | null;
stale: boolean;
antecedents: number;
companyOverdue: number;
verificationOverdue: number;
upcomingControls: number;
actionableAssets: number;
excludedAssets: number;
items: InspectionVisitChecklistItem[];
}
export interface InspectionVisitListItem {
id: string;
code: string;
objective: string | null;
status: InspectionVisitStatus;
scopeAsset: InspectionAssetSummary | null;
operationalArea: InspectionPlanningContextAsset | null;
operatorCompany: InspectionPlanningContextAsset | null;
leadInspector: InspectionPerson | null;
plannedStartAt: Date | null;
actualStartedAt: Date | null;
actualClosedAt: Date | null;
instructions: string | null;
cancellationReason: string | null;
checklistGeneration: number;
checklistGeneratedAt: Date | null;
assetCount: number;
memberCount: number;
createdAt: Date;
updatedAt: Date;
}
export interface InspectionVerificationFindingSummary {
id: string;
code: string;
title: string;
status: string;
nextControlOn: string | null;
assetId: string;
assetCode: string;
assetName: string;
targetControlOn: string | null;
outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null;
resultNotes: string | null;
verifiedAt: Date | null;
resultRecordedAt: Date | null;
rescheduledControlOn: string | null;
verificationEvidenceCount: number;
}
export interface InspectionVisitView extends InspectionVisitListItem {
assets: InspectionAssetSummary[];
planningAssets: InspectionPlannedAsset[];
team: InspectionPerson[];
verificationFindings: InspectionVerificationFindingSummary[];
checklist: InspectionVisitChecklistSummary;
}
function visitNotFound(): NotFoundException {
return new NotFoundException({
code: 'INSPECTION_VISIT_NOT_FOUND',
message: 'Visita de inspección no encontrada',
});
}
function dateValue(value: string | null | undefined): Date | null {
return value ? new Date(value) : null;
}
@Injectable()
export class InspectionVisitsService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async list(query: ListInspectionVisitsQueryDto) {
const conditions: string[] = [];
const parameters: unknown[] = [];
const add = (value: unknown): string => {
parameters.push(value);
return `$${parameters.length}`;
};
if (query.search?.trim()) {
const search = add(`%${query.search.trim()}%`);
conditions.push(`visit.code ILIKE ${search}`);
}
if (query.status) conditions.push(`visit.status = ${add(query.status)}`);
if (query.companyId) {
const company = add(query.companyId);
conditions.push(`(
visit.operator_company_id = ${company}::uuid
OR visit.scope_asset_id = ${company}::uuid
OR EXISTS (
SELECT 1
FROM inspection_visit_assets company_link
INNER JOIN assets company_asset ON company_asset.id = company_link.asset_id
WHERE company_link.visit_id = visit.id
AND company_link.included = true
AND company_asset.operator_company_id = ${company}::uuid
)
)`);
}
if (query.areaId) {
const area = add(query.areaId);
conditions.push(`(
visit.operational_area_id = ${area}::uuid
OR visit.scope_asset_id = ${area}::uuid
OR EXISTS (
SELECT 1
FROM inspection_visit_assets area_link
INNER JOIN assets area_asset ON area_asset.id = area_link.asset_id
WHERE area_link.visit_id = visit.id
AND area_link.included = true
AND area_asset.operational_area_id = ${area}::uuid
)
)`);
}
if (query.inspectorId) {
const inspector = add(query.inspectorId);
conditions.push(`(
visit.lead_inspector_user_id = ${inspector}::uuid
OR EXISTS (
SELECT 1 FROM inspection_visit_members member_filter
WHERE member_filter.visit_id = visit.id
AND member_filter.included = true
AND member_filter.user_id = ${inspector}::uuid
)
)`);
}
if (query.dateFrom) {
conditions.push(`COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at)::date >= ${add(query.dateFrom)}::date`);
}
if (query.dateTo) {
conditions.push(`COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at)::date <= ${add(query.dateTo)}::date`);
}
const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : '';
const [countRow] = (await this.dataSource.query(
`SELECT COUNT(*)::integer AS total FROM inspection_visits visit ${where}`,
parameters,
)) as Array<{ total: number }>;
const total = Number(countRow?.total ?? 0);
const limit = add(query.pageSize);
const offset = add((query.page - 1) * query.pageSize);
const data = (await this.dataSource.query(
`${this.visitSelect(where)}
ORDER BY
CASE visit.status
WHEN 'IN_PROGRESS' THEN 1 WHEN 'PLANNED' THEN 2 WHEN 'DRAFT' THEN 3
WHEN 'CLOSED' THEN 4 ELSE 5
END,
visit.planned_start_at NULLS LAST,
visit.updated_at DESC
LIMIT ${limit} OFFSET ${offset}`,
parameters,
)) as InspectionVisitListItem[];
return {
data,
meta: {
page: query.page,
pageSize: query.pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize),
},
};
}
async listAssignees(): Promise<{ data: InspectionPerson[] }> {
const data = (await this.dataSource.query(`
SELECT DISTINCT
user_account.id,
user_account.username,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName"
FROM users user_account
INNER JOIN user_roles user_role ON user_role.user_id = user_account.id
INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id
INNER JOIN permissions permission ON permission.id = role_permission.permission_id
WHERE user_account.status = 'ACTIVE'
AND permission.code = 'inspections.execute'
ORDER BY user_account.last_name, user_account.first_name, user_account.username
`)) as InspectionPerson[];
return { data };
}
async listPlanningAreas(): Promise<{ data: InspectionPlanningContextAsset[] }> {
const data = (await this.dataSource.query(`
SELECT DISTINCT area.id, area.code, area.name
FROM assets area
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
INNER JOIN area_company_relations relation ON relation.area_id = area.id
WHERE area_type.operational_role = 'AREA'
AND area.information_status <> 'INACTIVE'
AND relation.relation_role = 'OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
ORDER BY area.name, area.code
`)) as InspectionPlanningContextAsset[];
return { data };
}
async listPlanningOperators(areaId: string): Promise<{ data: InspectionPlanningContextAsset[] }> {
const data = (await this.dataSource.query(`
SELECT DISTINCT company.id, company.code, company.name
FROM area_company_relations relation
INNER JOIN assets company ON company.id = relation.company_id
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
WHERE relation.area_id = $1
AND relation.relation_role = 'OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
AND company_type.operational_role = 'COMPANY'
AND company.information_status <> 'INACTIVE'
ORDER BY company.name, company.code
`, [areaId])) as InspectionPlanningContextAsset[];
return { data };
}
async getById(id: string): Promise<InspectionVisitView> {
return this.dataSource.transaction((manager) => this.loadView(manager, id));
}
async create(
dto: CreateInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
try {
return await this.dataSource.transaction(async (manager) => {
const plannedStartAt = dateValue(dto.plannedStartAt);
if (!plannedStartAt || !Number.isFinite(plannedStartAt.getTime())) {
throw new BadRequestException({
code: 'INSPECTION_START_DATE_REQUIRED',
message: 'Indicá una fecha y hora prevista de inicio válida',
});
}
const operationalAreaId = dto.operationalAreaId;
const operatorCompanyId = dto.operatorCompanyId;
await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId);
await this.requireAsset(manager, operationalAreaId);
await this.requireInspectors(manager, [dto.leadInspectorUserId]);
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const visit = manager.getRepository(InspectionVisit).create({
code,
objective: null,
status: InspectionVisitStatus.DRAFT,
scopeAssetId: operationalAreaId,
operationalAreaId,
operatorCompanyId,
leadInspectorUserId: dto.leadInspectorUserId,
plannedStartAt,
actualStartedAt: null,
actualClosedAt: null,
instructions: null,
cancellationReason: null,
checklistGeneration: 0,
checklistGeneratedAt: null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(InspectionVisit).save(visit);
await this.replaceMemberLinks(
manager,
visit.id,
[dto.leadInspectorUserId],
principal.userId,
);
await this.generateChecklistInternal(manager, visit, principal.userId);
const created = await this.loadView(manager, visit.id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_CREATED,
entityType: 'inspection_visit',
entityId: visit.id,
afterData: this.auditView(created),
}, manager);
return created;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.codeConflict();
throw error;
}
}
async update(
id: string,
dto: UpdateInspectionVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
if (Object.keys(dto).length === 0) {
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
}
try {
return await this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
this.assertPlanningEditable(visit);
const before = await this.loadView(manager, id);
const nextStart = dto.plannedStartAt === undefined
? visit.plannedStartAt
: dateValue(dto.plannedStartAt);
const nextAreaId = dto.operationalAreaId === undefined
? visit.operationalAreaId
: dto.operationalAreaId;
const nextCompanyId = dto.operatorCompanyId === undefined
? visit.operatorCompanyId
: dto.operatorCompanyId;
const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;
const nextScope = contextTouched
? nextAreaId
: dto.scopeAssetId === undefined
? visit.scopeAssetId
: dto.scopeAssetId;
await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);
await this.requireAsset(manager, nextScope);
if (contextTouched && nextAreaId && nextCompanyId) {
await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId);
} else if (dto.scopeAssetId !== undefined) {
await this.assertCurrentAssetsInScope(manager, id, nextScope);
}
if (dto.objective !== undefined) visit.objective = dto.objective;
if (contextTouched) {
visit.operationalAreaId = nextAreaId;
visit.operatorCompanyId = nextCompanyId;
visit.scopeAssetId = nextAreaId;
} else if (dto.scopeAssetId !== undefined) {
visit.scopeAssetId = dto.scopeAssetId;
}
if (dto.plannedStartAt !== undefined) visit.plannedStartAt = nextStart;
if (dto.instructions !== undefined) visit.instructions = dto.instructions;
if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = 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_UPDATED,
entityType: 'inspection_visit',
entityId: id,
beforeData: this.auditView(before),
afterData: this.auditView(updated),
}, manager);
return updated;
});
} catch (error) {
if (isUniqueViolation(error)) throw this.codeConflict();
throw error;
}
}
async replaceAssets(
id: string,
dto: ReplaceInspectionVisitAssetsDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
return this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
this.assertPlanningEditable(visit);
await this.requireAssets(manager, dto.assetIds);
const before = await this.activeAssetIds(manager, id);
const removed = before.filter((assetId) => !dto.assetIds.includes(assetId));
if (removed.length > 0) {
throw new BadRequestException({
code: 'INSPECTION_EXCLUSION_REASON_REQUIRED',
message: 'Para retirar un registro del checklist usá la exclusión con motivo auditable',
});
}
const additions = dto.assetIds.filter((assetId) => !before.includes(assetId));
if (additions.length > 0) {
const excludedExisting = (await manager.query(`
SELECT asset_id AS id
FROM inspection_visit_assets
WHERE visit_id = $1
AND asset_id = ANY($2::uuid[])
AND included = false
`, [id, additions])) as Array<{ id: string }>;
if (excludedExisting.length > 0) {
throw new BadRequestException({
code: 'INSPECTION_REINCLUDE_REQUIRED',
message: 'Un registro excluido debe reincorporarse con la acción específica para conservar su trazabilidad',
});
}
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({
code: 'INSPECTION_CONTEXT_REQUIRED',
message: 'Seleccioná Área y Operadora antes de agregar registros preventivos',
});
}
await this.assertAssetsMatchContext(
manager,
additions,
visit.operationalAreaId,
visit.operatorCompanyId,
);
for (const assetId of additions) {
await manager.query(`
INSERT INTO inspection_visit_assets (
visit_id, asset_id, included, planning_source, exclusion_reason,
excluded_by, excluded_at, added_by
) VALUES ($1, $2, true, 'PREVENTIVE', NULL, NULL, NULL, $3)
ON CONFLICT (visit_id, asset_id) DO UPDATE SET
included = true,
planning_source = CASE
WHEN inspection_visit_assets.planning_source = 'VERIFICATION' THEN 'VERIFICATION'
ELSE 'PREVENTIVE'
END,
exclusion_reason = NULL,
excluded_by = NULL,
excluded_at = NULL,
added_by = EXCLUDED.added_by,
updated_at = CURRENT_TIMESTAMP
`, [id, assetId, principal.userId]);
await this.appendAssetEvent(
manager,
id,
assetId,
'PREVENTIVE_INCLUDED',
null,
principal.userId,
{ source: 'web_planning' },
);
}
}
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_ASSETS_UPDATED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { assetIds: before },
afterData: { assetIds: await this.activeAssetIds(manager, id), preventiveAdded: additions },
}, manager);
return this.loadView(manager, id);
});
}
async generateChecklist(
id: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
return this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
this.assertPlanningEditable(visit);
const beforeGeneration = visit.checklistGeneration;
await this.generateChecklistInternal(manager, visit, principal.userId);
const updated = await this.loadView(manager, id);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_CHECKLIST_GENERATED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { checklistGeneration: beforeGeneration },
afterData: {
checklistGeneration: updated.checklist.generation,
checklistGeneratedAt: updated.checklist.generatedAt,
summary: {
antecedents: updated.checklist.antecedents,
companyOverdue: updated.checklist.companyOverdue,
verificationOverdue: updated.checklist.verificationOverdue,
upcomingControls: updated.checklist.upcomingControls,
actionableAssets: updated.checklist.actionableAssets,
},
},
}, manager);
return updated;
});
}
async excludeAsset(
id: string,
assetId: string,
reason: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
return this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
this.assertPlanningEditable(visit);
const [link] = (await manager.query(`
SELECT included, planning_source AS "planningSource"
FROM inspection_visit_assets
WHERE visit_id = $1 AND asset_id = $2
FOR UPDATE
`, [id, assetId])) as Array<{ included: boolean; planningSource: InspectionVisitAssetPlanningSource }>;
if (!link) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_ASSET_NOT_FOUND',
message: 'El registro no forma parte del checklist de esta visita',
});
}
if (!link.included) return this.loadView(manager, id);
await manager.query(`
UPDATE inspection_visit_assets
SET included = false,
exclusion_reason = $3,
excluded_by = $4,
excluded_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE visit_id = $1 AND asset_id = $2
`, [id, assetId, reason.trim(), principal.userId]);
await this.appendAssetEvent(
manager,
id,
assetId,
'EXCLUDED',
reason.trim(),
principal.userId,
{ planningSource: link.planningSource },
);
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_ASSET_EXCLUDED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { assetId, included: true },
afterData: { assetId, included: false, reason: reason.trim() },
}, manager);
return this.loadView(manager, id);
});
}
async includeAsset(
id: string,
assetId: string,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
return this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
this.assertPlanningEditable(visit);
const [link] = (await manager.query(`
SELECT included, planning_source AS "planningSource", exclusion_reason AS "exclusionReason"
FROM inspection_visit_assets
WHERE visit_id = $1 AND asset_id = $2
FOR UPDATE
`, [id, assetId])) as Array<{
included: boolean;
planningSource: InspectionVisitAssetPlanningSource;
exclusionReason: string | null;
}>;
if (!link) {
throw new NotFoundException({
code: 'INSPECTION_VISIT_ASSET_NOT_FOUND',
message: 'El registro no forma parte del checklist de esta visita',
});
}
if (link.included) return this.loadView(manager, id);
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({ code: 'INSPECTION_CONTEXT_REQUIRED', message: 'La visita no tiene Área y Operadora definidas' });
}
await this.assertAssetsMatchContext(
manager,
[assetId],
visit.operationalAreaId,
visit.operatorCompanyId,
);
await manager.query(`
UPDATE inspection_visit_assets
SET included = true,
exclusion_reason = NULL,
excluded_by = NULL,
excluded_at = NULL,
updated_at = CURRENT_TIMESTAMP
WHERE visit_id = $1 AND asset_id = $2
`, [id, assetId]);
await this.appendAssetEvent(
manager,
id,
assetId,
'REINCLUDED',
link.exclusionReason,
principal.userId,
{ planningSource: link.planningSource },
);
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_ASSET_REINCLUDED,
entityType: 'inspection_visit',
entityId: id,
beforeData: { assetId, included: false, reason: link.exclusionReason },
afterData: { assetId, included: true },
}, manager);
return this.loadView(manager, id);
});
}
async replaceTeam(
id: string,
dto: ReplaceInspectionVisitTeamDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<InspectionVisitView> {
return this.dataSource.transaction(async (manager) => {
const visit = await this.lockVisit(manager, id);
this.assertPlanningEditable(visit);
const leadInspectorUserId = dto.leadInspectorUserId ?? null;
await this.requireInspectors(manager, dto.memberUserIds);
if (leadInspectorUserId && !dto.memberUserIds.includes(leadInspectorUserId)) {
throw new BadRequestException({
code: 'INSPECTION_LEAD_NOT_IN_TEAM',
message: 'El inspector responsable debe formar parte del equipo',
});
}
const before = {
leadInspectorUserId: visit.leadInspectorUserId,
memberUserIds: await this.activeMemberIds(manager, id),
};
visit.leadInspectorUserId = leadInspectorUserId;
visit.updatedBy = principal.userId;
await manager.getRepository(InspectionVisit).save(visit);
await this.replaceMemberLinks(manager, id, dto.memberUserIds, principal.userId);
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VISIT_TEAM_UPDATED,
entityType: 'inspection_visit',
entityId: id,
beforeData: before,
afterData: {
leadInspectorUserId,
memberUserIds: dto.memberUserIds,
},
}, manager);
return this.loadView(manager, id);
});
}
private visitSelect(where: string): string {
return `
SELECT
visit.id,
visit.code,
visit.objective,
visit.status,
CASE WHEN scope.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', scope.id,
'code', scope.code,
'name', scope.name,
'typeName', scope_type.name
) END AS "scopeAsset",
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', area.id,
'code', area.code,
'name', area.name
) END AS "operationalArea",
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', company.id,
'code', company.code,
'name', company.name
) END AS "operatorCompany",
CASE WHEN lead.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', lead.id,
'username', lead.username,
'firstName', lead.first_name,
'lastName', lead.last_name
) END AS "leadInspector",
visit.planned_start_at AS "plannedStartAt",
visit.actual_started_at AS "actualStartedAt",
visit.actual_closed_at AS "actualClosedAt",
visit.instructions,
visit.cancellation_reason AS "cancellationReason",
visit.checklist_generation AS "checklistGeneration",
visit.checklist_generated_at AS "checklistGeneratedAt",
COALESCE(link_counts.assets, 0)::integer AS "assetCount",
COALESCE(link_counts.members, 0)::integer AS "memberCount",
visit.created_at AS "createdAt",
visit.updated_at AS "updatedAt"
FROM inspection_visits visit
LEFT JOIN assets scope ON scope.id = visit.scope_asset_id
LEFT JOIN asset_types scope_type ON scope_type.id = scope.asset_type_id
LEFT JOIN assets area ON area.id = visit.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_id
LEFT JOIN users lead ON lead.id = visit.lead_inspector_user_id
LEFT JOIN LATERAL (
SELECT
(SELECT COUNT(*) FROM inspection_visit_assets asset_link
WHERE asset_link.visit_id = visit.id AND asset_link.included = true) AS assets,
(SELECT COUNT(*) FROM inspection_visit_members member_link
WHERE member_link.visit_id = visit.id AND member_link.included = true) AS members
) link_counts ON true
${where}
`;
}
private async loadView(manager: EntityManager, id: string): Promise<InspectionVisitView> {
const [visit] = (await manager.query(
this.visitSelect('WHERE visit.id = $1'),
[id],
)) as InspectionVisitListItem[];
if (!visit) throw visitNotFound();
const assets = (await manager.query(`
SELECT asset.id, asset.code, asset.name, asset_type.name AS "typeName"
FROM inspection_visit_assets link
INNER JOIN assets asset ON asset.id = link.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
WHERE link.visit_id = $1 AND link.included = true
ORDER BY asset.code, asset.name
`, [id])) as InspectionAssetSummary[];
const planningAssets = (await manager.query(`
SELECT
asset.id,
asset.code,
asset.name,
asset_type.name AS "typeName",
link.included,
link.planning_source AS "planningSource",
link.exclusion_reason AS "exclusionReason",
link.excluded_at AS "excludedAt",
CASE WHEN excluded_user.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id', excluded_user.id,
'username', excluded_user.username,
'firstName', excluded_user.first_name,
'lastName', excluded_user.last_name
) END AS "excludedBy"
FROM inspection_visit_assets link
INNER JOIN assets asset ON asset.id = link.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN users excluded_user ON excluded_user.id = link.excluded_by
WHERE link.visit_id = $1
ORDER BY link.included DESC, asset.code, asset.name
`, [id])) as InspectionPlannedAsset[];
const team = (await manager.query(`
SELECT
user_account.id,
user_account.username,
user_account.first_name AS "firstName",
user_account.last_name AS "lastName"
FROM inspection_visit_members link
INNER JOIN users user_account ON user_account.id = link.user_id
WHERE link.visit_id = $1 AND link.included = true
ORDER BY
CASE WHEN user_account.id = $2 THEN 0 ELSE 1 END,
user_account.last_name,
user_account.first_name
`, [id, visit.leadInspector?.id ?? null])) as InspectionPerson[];
const verificationFindings = (await manager.query(`
SELECT
finding.id,
finding.code,
finding.title,
finding.status,
finding.next_control_on AS "nextControlOn",
asset.id AS "assetId",
asset.code AS "assetCode",
asset.name AS "assetName",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "resultNotes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "resultRecordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
(SELECT COUNT(*)::integer
FROM inspection_finding_evidence evidence
WHERE evidence.finding_id = finding.id
AND evidence.verification_visit_id = verification_link.visit_id
AND evidence.purpose = 'VERIFICATION') AS "verificationEvidenceCount"
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id
INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE verification_link.visit_id = $1
ORDER BY finding.next_control_on NULLS LAST, finding.code
`, [id])) as InspectionVerificationFindingSummary[];
const checklistItems = visit.checklistGeneration > 0
? (await manager.query(`
SELECT
item.id,
item.finding_id AS "findingId",
item.finding_code AS "findingCode",
item.finding_title AS "findingTitle",
item.finding_status AS "findingStatus",
item.severity,
item.item_kind AS "itemKind",
item.reference_on AS "referenceOn",
JSONB_BUILD_OBJECT(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'typeName', asset_type.name
) AS asset,
COALESCE(link.included, false) AS "assetIncluded"
FROM inspection_visit_checklist_items item
INNER JOIN assets asset ON asset.id = item.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN inspection_visit_assets link
ON link.visit_id = item.visit_id AND link.asset_id = item.asset_id
WHERE item.visit_id = $1 AND item.generation_number = $2
ORDER BY
CASE item.item_kind
WHEN 'VERIFICATION_OVERDUE' THEN 1
WHEN 'COMPANY_OVERDUE' THEN 2
WHEN 'UPCOMING_CONTROL' THEN 3
ELSE 4
END,
item.reference_on NULLS LAST,
item.finding_code
`, [id, visit.checklistGeneration])) as InspectionVisitChecklistItem[]
: [];
const actionableAssets = new Set(
checklistItems
.filter((item) => item.itemKind !== 'ANTECEDENT')
.map((item) => item.asset.id),
).size;
const checklist: InspectionVisitChecklistSummary = {
generation: visit.checklistGeneration,
generatedAt: visit.checklistGeneratedAt,
stale: visit.checklistGeneration > 0 && !visit.checklistGeneratedAt,
antecedents: checklistItems.filter((item) => item.itemKind === 'ANTECEDENT').length,
companyOverdue: checklistItems.filter((item) => item.itemKind === 'COMPANY_OVERDUE').length,
verificationOverdue: checklistItems.filter((item) => item.itemKind === 'VERIFICATION_OVERDUE').length,
upcomingControls: checklistItems.filter((item) => item.itemKind === 'UPCOMING_CONTROL').length,
actionableAssets,
excludedAssets: planningAssets.filter((asset) => !asset.included).length,
items: checklistItems,
};
return { ...visit, assets, planningAssets, team, verificationFindings, checklist };
}
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 visitNotFound();
return visit;
}
private assertPlanningEditable(visit: InspectionVisit): void {
if (![InspectionVisitStatus.DRAFT, InspectionVisitStatus.PLANNED].includes(visit.status)) {
throw new ConflictException({
code: 'INSPECTION_VISIT_PLANNING_LOCKED',
message: 'Una visita iniciada, cerrada o cancelada ya no puede replanificarse',
});
}
}
private async validatePlanningContext(
manager: EntityManager,
operationalAreaId: string | null,
operatorCompanyId: string | null,
): Promise<void> {
if (!operationalAreaId && !operatorCompanyId) return;
if (!operationalAreaId || !operatorCompanyId) {
throw new BadRequestException({
code: 'INSPECTION_CONTEXT_PAIR_REQUIRED',
message: 'Área y Operadora deben seleccionarse juntas',
});
}
const [context] = (await manager.query(`
SELECT
area.id AS "areaId",
company.id AS "companyId",
EXISTS (
SELECT 1
FROM area_company_relations relation
WHERE relation.area_id = area.id
AND relation.company_id = company.id
AND relation.relation_role = 'OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
) AS "activeOperatorRelation"
FROM assets area
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
CROSS JOIN assets company
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
WHERE area.id = $1
AND company.id = $2
AND area_type.operational_role = 'AREA'
AND company_type.operational_role = 'COMPANY'
AND area.information_status <> 'INACTIVE'
AND company.information_status <> 'INACTIVE'
`, [operationalAreaId, operatorCompanyId])) as Array<{
areaId: string;
companyId: string;
activeOperatorRelation: boolean;
}>;
if (!context) {
throw new BadRequestException({
code: 'INSPECTION_CONTEXT_INVALID',
message: 'El Área o la Organización operadora no son válidas para planificación',
});
}
if (!context.activeOperatorRelation) {
throw new BadRequestException({
code: 'INSPECTION_OPERATOR_NOT_ACTIVE_IN_AREA',
message: 'La Organización seleccionada no tiene relación OPERATOR vigente con el Área',
});
}
}
private async assertAssetsMatchContext(
manager: EntityManager,
assetIds: string[],
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
if (assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS outside
FROM assets asset
WHERE asset.id = ANY($1::uuid[])
AND (
asset.operational_area_id IS DISTINCT FROM $2::uuid
OR asset.operator_company_id IS DISTINCT FROM $3::uuid
)
`, [assetIds, operationalAreaId, operatorCompanyId])) as Array<{ outside: number }>;
if (Number(row?.outside ?? 0) > 0) {
throw new BadRequestException({
code: 'INSPECTION_ASSET_OUTSIDE_OPERATIONAL_CONTEXT',
message: 'Uno o más registros no pertenecen al Área y Operadora de la visita',
});
}
}
private async assertCurrentAssetsMatchContext(
manager: EntityManager,
visitId: string,
operationalAreaId: string,
operatorCompanyId: string,
): Promise<void> {
await this.assertAssetsMatchContext(
manager,
await this.activeAssetIds(manager, visitId),
operationalAreaId,
operatorCompanyId,
);
}
private async appendAssetEvent(
manager: EntityManager,
visitId: string,
assetId: string,
eventType: 'LEGACY_INCLUDED' | 'AUTO_INCLUDED' | 'PREVENTIVE_INCLUDED' | 'VERIFICATION_INCLUDED' | 'EXCLUDED' | 'REINCLUDED',
reason: string | null,
actorUserId: string | null,
metadata: Record<string, unknown> | null,
): Promise<void> {
await manager.query(`
INSERT INTO inspection_visit_asset_events (
visit_id, asset_id, event_type, reason, actor_user_id, metadata
) VALUES ($1, $2, $3, $4, $5, $6::jsonb)
`, [visitId, assetId, eventType, reason, actorUserId, JSON.stringify(metadata ?? {})]);
}
private async generateChecklistInternal(
manager: EntityManager,
visit: InspectionVisit,
actorUserId: string,
): Promise<void> {
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({
code: 'INSPECTION_CONTEXT_REQUIRED',
message: 'Seleccioná primero el Área y la Organización operadora',
});
}
if (!visit.plannedStartAt) {
throw new BadRequestException({
code: 'INSPECTION_START_DATE_REQUIRED',
message: 'Indicá la fecha prevista antes de generar el checklist',
});
}
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
const generation = visit.checklistGeneration + 1;
await manager.query(`
INSERT INTO inspection_visit_checklist_items (
visit_id, generation_number, finding_id, asset_id, item_kind,
reference_on, finding_status, finding_code, finding_title, severity
)
SELECT
$1,
$2,
finding.id,
finding.asset_id,
CASE
WHEN finding.status = 'OPEN'
AND finding.company_response_received_on IS NULL
AND finding.correction_due_on IS NOT NULL
AND finding.correction_due_on < $5::date
THEN 'COMPANY_OVERDUE'
WHEN finding.status = 'OPEN'
AND finding.company_response_received_on IS NOT NULL
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on < $5::date
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id = finding.id
AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN 'VERIFICATION_OVERDUE'
WHEN finding.status = 'OPEN'
AND finding.company_response_received_on IS NOT NULL
AND finding.next_control_on IS NOT NULL
AND finding.next_control_on BETWEEN $5::date AND ($5::date + 30)
AND COALESCE((
SELECT verification.outcome
FROM inspection_finding_verification_visits verification
WHERE verification.finding_id = finding.id
AND verification.outcome IS NOT NULL
ORDER BY verification.result_recorded_at DESC NULLS LAST, verification.created_at DESC, verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'
THEN 'UPCOMING_CONTROL'
ELSE 'ANTECEDENT'
END,
CASE
WHEN finding.status = 'OPEN'
AND finding.company_response_received_on IS NULL
AND finding.correction_due_on IS NOT NULL
AND finding.correction_due_on < $5::date
THEN finding.correction_due_on
WHEN finding.status = 'OPEN'
AND finding.company_response_received_on IS NOT NULL
AND finding.next_control_on IS NOT NULL
THEN finding.next_control_on
ELSE NULL
END,
finding.status,
finding.code,
finding.title,
finding.severity
FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id
WHERE asset.operational_area_id = $3
AND asset.operator_company_id = $4
AND finding.status <> 'VOIDED'
ORDER BY finding.created_at, finding.id
`, [
visit.id,
generation,
visit.operationalAreaId,
visit.operatorCompanyId,
visit.plannedStartAt.toISOString().slice(0, 10),
]);
const candidates = (await manager.query(`
SELECT DISTINCT asset_id AS id
FROM inspection_visit_checklist_items
WHERE visit_id = $1
AND generation_number = $2
AND item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')
ORDER BY asset_id
`, [visit.id, generation])) as Array<{ id: string }>;
const candidateIds = candidates.map((row) => row.id);
const obsolete = (await manager.query(`
SELECT asset_id AS id
FROM inspection_visit_assets
WHERE visit_id = $1
AND planning_source = 'AUTOMATIC'
AND included = true
AND NOT (asset_id = ANY($2::uuid[]))
FOR UPDATE
`, [visit.id, candidateIds])) as Array<{ id: string }>;
const obsoleteReason = 'Ya no forma parte del checklist automático vigente';
for (const row of obsolete) {
await manager.query(`
UPDATE inspection_visit_assets
SET included = false,
exclusion_reason = $3,
excluded_by = $4,
excluded_at = CURRENT_TIMESTAMP,
updated_at = CURRENT_TIMESTAMP
WHERE visit_id = $1 AND asset_id = $2
`, [visit.id, row.id, obsoleteReason, actorUserId]);
await this.appendAssetEvent(
manager,
visit.id,
row.id,
'EXCLUDED',
obsoleteReason,
actorUserId,
{ automatic: true, generation },
);
}
for (const assetId of candidateIds) {
const [existing] = (await manager.query(`
SELECT included, planning_source AS "planningSource", exclusion_reason AS "exclusionReason"
FROM inspection_visit_assets
WHERE visit_id = $1 AND asset_id = $2
FOR UPDATE
`, [visit.id, assetId])) as Array<{
included: boolean;
planningSource: InspectionVisitAssetPlanningSource;
exclusionReason: string | null;
}>;
if (!existing) {
await manager.query(`
INSERT INTO inspection_visit_assets (
visit_id, asset_id, included, planning_source, added_by
) VALUES ($1, $2, true, 'AUTOMATIC', $3)
`, [visit.id, assetId, actorUserId]);
await this.appendAssetEvent(
manager,
visit.id,
assetId,
'AUTO_INCLUDED',
null,
actorUserId,
{ generation },
);
} else if (existing.planningSource === InspectionVisitAssetPlanningSource.LEGACY && existing.included) {
await manager.query(`
UPDATE inspection_visit_assets
SET planning_source = 'AUTOMATIC', updated_at = CURRENT_TIMESTAMP
WHERE visit_id = $1 AND asset_id = $2
`, [visit.id, assetId]);
}
}
visit.checklistGeneration = generation;
visit.checklistGeneratedAt = new Date();
visit.updatedBy = actorUserId;
await manager.getRepository(InspectionVisit).save(visit);
}
private async requireAsset(manager: EntityManager, id: string | null): Promise<void> {
if (!id) return;
const rows = await manager.query('SELECT 1 FROM assets WHERE id = $1', [id]) as unknown[];
if (rows.length === 0) {
throw new BadRequestException({
code: 'INSPECTION_SCOPE_ASSET_NOT_FOUND',
message: 'El activo de ubicación o alcance no existe',
});
}
}
private async requireAssets(manager: EntityManager, ids: string[]): Promise<void> {
if (ids.length === 0) return;
const [row] = (await manager.query(
`SELECT COUNT(*)::integer AS count FROM assets WHERE id = ANY($1::uuid[])`,
[ids],
)) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== ids.length) {
throw new BadRequestException({
code: 'INSPECTION_ASSET_NOT_FOUND',
message: 'Uno o más activos seleccionados no existen',
});
}
}
private async requireInspectors(manager: EntityManager, ids: string[]): Promise<void> {
if (ids.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS count
FROM users user_account
WHERE user_account.id = ANY($1::uuid[])
AND user_account.status = 'ACTIVE'
AND EXISTS (
SELECT 1
FROM user_roles user_role
INNER JOIN role_permissions role_permission ON role_permission.role_id = user_role.role_id
INNER JOIN permissions permission ON permission.id = role_permission.permission_id
WHERE user_role.user_id = user_account.id
AND permission.code = 'inspections.execute'
)
`, [ids])) as Array<{ count: number }>;
if (Number(row?.count ?? 0) !== ids.length) {
throw new BadRequestException({
code: 'INSPECTION_MEMBER_INVALID',
message: 'Todo integrante debe ser un usuario activo con permiso de ejecución',
});
}
}
private async assertAssetsInScope(
manager: EntityManager,
assetIds: string[],
scopeAssetId: string | null,
): Promise<void> {
if (!scopeAssetId || assetIds.length === 0) return;
const [row] = (await manager.query(`
SELECT COUNT(*)::integer AS outside
FROM UNNEST($1::uuid[]) selected(asset_id)
WHERE NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT asset.id, asset.parent_id FROM assets asset WHERE asset.id = selected.asset_id
UNION ALL
SELECT parent.id, parent.parent_id
FROM assets parent
INNER JOIN ancestors child ON child.parent_id = parent.id
)
SELECT 1 FROM ancestors WHERE id = $2
)
`, [assetIds, scopeAssetId])) as Array<{ outside: number }>;
if (Number(row?.outside ?? 0) > 0) {
throw new BadRequestException({
code: 'INSPECTION_ASSET_OUTSIDE_SCOPE',
message: 'Uno o más activos no pertenecen al alcance jerárquico de la visita',
});
}
}
private async assertCurrentAssetsInScope(
manager: EntityManager,
visitId: string,
scopeAssetId: string | null,
): Promise<void> {
await this.assertAssetsInScope(
manager,
await this.activeAssetIds(manager, visitId),
scopeAssetId,
);
}
private async validatePlan(manager: EntityManager, visit: InspectionVisit): Promise<void> {
if (!visit.operationalAreaId || !visit.operatorCompanyId) {
throw new BadRequestException({
code: 'INSPECTION_CONTEXT_REQUIRED',
message: 'Seleccioná Área y Organización operadora antes de confirmar la planificación',
});
}
await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);
if (!visit.scopeAssetId) {
throw new BadRequestException({
code: 'INSPECTION_SCOPE_REQUIRED',
message: 'La visita no tiene un alcance operativo válido',
});
}
if (!visit.plannedStartAt) {
throw new BadRequestException({
code: 'INSPECTION_START_DATE_REQUIRED',
message: 'Indicá la fecha y hora prevista de inicio',
});
}
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',
});
}
if (!visit.leadInspectorUserId) {
throw new BadRequestException({
code: 'INSPECTION_LEAD_REQUIRED',
message: 'Asigná un inspector responsable',
});
}
await this.requireInspectors(manager, [visit.leadInspectorUserId]);
const memberIds = await this.activeMemberIds(manager, visit.id);
await this.requireInspectors(manager, memberIds);
const [counts] = (await manager.query(`
SELECT
(SELECT COUNT(*) FROM inspection_visit_assets
WHERE visit_id = $1 AND included = true)::integer AS assets,
(SELECT COUNT(*) FROM inspection_visit_members
WHERE visit_id = $1 AND included = true)::integer AS members,
EXISTS (
SELECT 1 FROM inspection_visit_members
WHERE visit_id = $1 AND user_id = $2 AND included = true
) AS "leadIncluded"
`, [visit.id, visit.leadInspectorUserId])) as Array<{
assets: number;
members: number;
leadIncluded: boolean;
}>;
if (Number(counts?.assets ?? 0) < 1) {
throw new BadRequestException({
code: 'INSPECTION_ASSETS_REQUIRED',
message: 'Agregá al menos un activo existente para inspeccionar',
});
}
if (memberIds.length < 1 || Number(counts?.members ?? 0) < 1 || !counts?.leadIncluded) {
throw new BadRequestException({
code: 'INSPECTION_TEAM_REQUIRED',
message: 'El equipo debe incluir al inspector responsable',
});
}
await this.assertCurrentAssetsInScope(manager, visit.id, visit.scopeAssetId);
await this.assertCurrentAssetsMatchContext(
manager,
visit.id,
visit.operationalAreaId,
visit.operatorCompanyId,
);
}
private async activeAssetIds(manager: EntityManager, visitId: string): Promise<string[]> {
const rows = (await manager.query(`
SELECT asset_id AS id FROM inspection_visit_assets
WHERE visit_id = $1 AND included = true ORDER BY created_at, asset_id
`, [visitId])) as Array<{ id: string }>;
return rows.map((row) => row.id);
}
private async activeMemberIds(manager: EntityManager, visitId: string): Promise<string[]> {
const rows = (await manager.query(`
SELECT user_id AS id FROM inspection_visit_members
WHERE visit_id = $1 AND included = true ORDER BY created_at, user_id
`, [visitId])) as Array<{ id: string }>;
return rows.map((row) => row.id);
}
private async replaceMemberLinks(
manager: EntityManager,
visitId: string,
memberIds: string[],
userId: string,
): Promise<void> {
await manager.query(`
UPDATE inspection_visit_members
SET included = false, updated_at = CURRENT_TIMESTAMP
WHERE visit_id = $1 AND included = true
`, [visitId]);
for (const memberId of memberIds) {
await manager.query(`
INSERT INTO inspection_visit_members (visit_id, user_id, included, assigned_by)
VALUES ($1, $2, true, $3)
ON CONFLICT (visit_id, user_id) DO UPDATE SET
included = true,
assigned_by = EXCLUDED.assigned_by,
updated_at = CURRENT_TIMESTAMP
`, [visitId, memberId, userId]);
}
}
private codeConflict(): ConflictException {
return new ConflictException({
code: 'INSPECTION_VISIT_CODE_EXISTS',
message: 'Ya existe una visita con ese código',
});
}
private auditView(visit: InspectionVisitView): Record<string, unknown> {
return {
code: visit.code,
objective: visit.objective,
status: visit.status,
scopeAssetId: visit.scopeAsset?.id ?? null,
operationalAreaId: visit.operationalArea?.id ?? null,
operatorCompanyId: visit.operatorCompany?.id ?? null,
leadInspectorUserId: visit.leadInspector?.id ?? null,
plannedStartAt: visit.plannedStartAt,
actualStartedAt: visit.actualStartedAt,
actualClosedAt: visit.actualClosedAt,
instructions: visit.instructions,
cancellationReason: visit.cancellationReason,
checklistGeneration: visit.checklist.generation,
checklistGeneratedAt: visit.checklist.generatedAt,
assetIds: visit.assets.map((asset) => asset.id),
excludedAssetIds: visit.planningAssets.filter((asset) => !asset.included).map((asset) => asset.id),
memberUserIds: visit.team.map((member) => member.id),
};
}
}