Implementa el nuevo núcleo operativo: múltiples Actas por Inspección, un Informe por Acta, cierre de campo independiente y firma de empresa diferida con conformidad/disidencia.
1639 lines
62 KiB
TypeScript
1639 lines
62 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
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 { 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 {
|
|
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;
|
|
title: string;
|
|
objective: string | null;
|
|
status: InspectionVisitStatus;
|
|
scopeAsset: InspectionAssetSummary | null;
|
|
operationalArea: InspectionPlanningContextAsset | null;
|
|
operatorCompany: InspectionPlanningContextAsset | null;
|
|
leadInspector: InspectionPerson | null;
|
|
plannedStartAt: Date | null;
|
|
plannedEndAt: 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} OR visit.title 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,
|
|
title: code,
|
|
objective: null,
|
|
status: InspectionVisitStatus.DRAFT,
|
|
scopeAssetId: operationalAreaId,
|
|
operationalAreaId,
|
|
operatorCompanyId,
|
|
leadInspectorUserId: dto.leadInspectorUserId,
|
|
plannedStartAt,
|
|
plannedEndAt: null,
|
|
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);
|
|
});
|
|
}
|
|
|
|
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(
|
|
'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.planned_end_at AS "plannedEndAt",
|
|
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,
|
|
title: visit.title,
|
|
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,
|
|
plannedEndAt: visit.plannedEndAt,
|
|
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),
|
|
};
|
|
}
|
|
}
|