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

777 lines
30 KiB
TypeScript

import { BadRequestException, ConflictException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
AuditAction,
InspectionFindingVersionEvent,
InspectionFindingVerificationEventType,
InspectionVerificationOutcome,
InspectionVisit,
InspectionVisitStatus,
} from '../database/entities';
import type { ListVerificationPlanningQueryDto } from './dto/list-verification-planning-query.dto';
import type { PlanVerificationVisitDto } from './dto/plan-verification-visit.dto';
import type { RecordVerificationResultDto } from './dto/record-verification-result.dto';
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
import { nextInspectionVisitCode } from '../inspection-visits/inspection-visit-code';
import { appendVerificationEvent } from './verification-event-ledger';
interface VerificationContextView {
id: string;
code: string;
name: string;
}
interface VerificationVisitView {
id: string;
code: string;
status: InspectionVisitStatus;
plannedStartAt: Date | null;
}
export interface VerificationPlanningItem {
id: string;
code: string;
title: string;
status: 'OPEN';
nextControlOn: string;
asset: VerificationContextView & { typeName: string };
company: VerificationContextView | null;
area: VerificationContextView | null;
act: { id: string; code: string };
sourceVisit: { id: string; code: string };
verificationVisit: VerificationVisitView | null;
}
export interface VerificationPlanningCounters {
eligible: number;
unplanned: number;
overdueUnplanned: number;
dueNext30Days: number;
planned: number;
}
export interface VerificationPlanningPage {
data: VerificationPlanningItem[];
meta: { page: number; pageSize: number; total: number; totalPages: number };
counters: VerificationPlanningCounters;
}
interface LockedFindingRow {
id: string;
code: string;
title: string;
assetId: string;
assetCode: string;
assetName: string;
areaId: string | null;
areaCode: string | null;
areaName: string | null;
nextControlOn: string | null;
status: string;
}
interface PlannedOperatorRow {
companyId: string;
companyCode: string;
companyName: string;
}
@Injectable()
export class InspectionVerificationsService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listQueue(query: ListVerificationPlanningQueryDto): Promise<VerificationPlanningPage> {
const page = query.page ?? 1;
const pageSize = query.pageSize ?? 50;
const values: unknown[] = [];
const baseFilters = [
`finding.status = 'OPEN'`,
`finding.next_control_on IS NOT NULL`,
`COALESCE((
SELECT latest_verification.outcome
FROM inspection_finding_verification_visits latest_verification
WHERE latest_verification.finding_id = finding.id
AND latest_verification.outcome IS NOT NULL
ORDER BY latest_verification.result_recorded_at DESC NULLS LAST, latest_verification.created_at DESC, latest_verification.id DESC
LIMIT 1
), '') <> 'RESOLVED'`,
];
const add = (value: unknown): string => {
values.push(value);
return `$${values.length}`;
};
if (query.search) {
const parameter = add(`%${query.search}%`);
baseFilters.push(`(
finding.code ILIKE ${parameter}
OR finding.title ILIKE ${parameter}
OR asset.code ILIKE ${parameter}
OR asset.name ILIKE ${parameter}
OR company.name ILIKE ${parameter}
OR area.name ILIKE ${parameter}
OR act.code ILIKE ${parameter}
)`);
}
if (query.companyId) baseFilters.push(`company.id = ${add(query.companyId)}`);
if (query.areaId) baseFilters.push(`area.id = ${add(query.areaId)}`);
if (query.dueFrom) baseFilters.push(`finding.next_control_on >= ${add(query.dueFrom)}::date`);
if (query.dueTo) baseFilters.push(`finding.next_control_on <= ${add(query.dueTo)}::date`);
const baseWhere = `WHERE ${baseFilters.join(' AND ')}`;
const activeVisitJoin = `
LEFT JOIN LATERAL (
SELECT
visit.id,
visit.code,
visit.status,
visit.planned_start_at AS "plannedStartAt"
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE verification_link.finding_id = finding.id
AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')
ORDER BY verification_link.created_at DESC, verification_link.id DESC
LIMIT 1
) verification_visit ON true
`;
const joins = `
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
LEFT JOIN LATERAL (
SELECT operator_company.id,operator_company.code,operator_company.name
FROM area_company_relations relation
INNER JOIN assets operator_company ON operator_company.id=relation.company_id
WHERE relation.area_id=asset.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= CURRENT_TIMESTAMP
AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)
AND operator_company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
) company ON true
${activeVisitJoin}
`;
const planningFilters: string[] = [];
if (query.planningStatus === 'UNPLANNED') planningFilters.push('verification_visit.id IS NULL');
if (query.planningStatus === 'PLANNED') planningFilters.push('verification_visit.id IS NOT NULL');
const listWhere = planningFilters.length
? `${baseWhere} AND ${planningFilters.join(' AND ')}`
: baseWhere;
const countRows = await this.dataSource.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_findings finding
${joins}
${listWhere}
`, values) as Array<{ total: number }>;
const total = Number(countRows[0]?.total ?? 0);
const listValues = [...values];
listValues.push(pageSize);
const limitParameter = `$${listValues.length}`;
listValues.push((page - 1) * pageSize);
const offsetParameter = `$${listValues.length}`;
const data = await this.dataSource.query(`
SELECT
finding.id,
finding.code,
finding.title,
finding.status,
finding.next_control_on AS "nextControlOn",
jsonb_build_object(
'id', asset.id,
'code', asset.code,
'name', asset.name,
'typeName', asset_type.name
) AS asset,
CASE WHEN company.id IS NULL THEN NULL ELSE jsonb_build_object(
'id', company.id,
'code', company.code,
'name', company.name
) END AS company,
CASE WHEN area.id IS NULL THEN NULL ELSE jsonb_build_object(
'id', area.id,
'code', area.code,
'name', area.name
) END AS area,
jsonb_build_object('id', act.id, 'code', act.code) AS act,
jsonb_build_object(
'id', source_visit.id,
'code', source_visit.code
) AS "sourceVisit",
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE jsonb_build_object(
'id', verification_visit.id,
'code', verification_visit.code,
'status', verification_visit.status,
'plannedStartAt', verification_visit."plannedStartAt"
) END AS "verificationVisit"
FROM inspection_findings finding
${joins}
${listWhere}
ORDER BY
CASE WHEN finding.next_control_on < (CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date THEN 0 ELSE 1 END,
finding.next_control_on,
company.name NULLS LAST,
area.name NULLS LAST,
finding.code
LIMIT ${limitParameter} OFFSET ${offsetParameter}
`, listValues) as VerificationPlanningItem[];
const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`;
const counterRows = await this.dataSource.query(`
SELECT
COUNT(*)::integer AS eligible,
COUNT(*) FILTER (WHERE verification_visit.id IS NULL)::integer AS unplanned,
COUNT(*) FILTER (
WHERE verification_visit.id IS NULL AND finding.next_control_on < ${today}
)::integer AS "overdueUnplanned",
COUNT(*) FILTER (
WHERE verification_visit.id IS NULL
AND finding.next_control_on BETWEEN ${today} AND ${today} + 30
)::integer AS "dueNext30Days",
COUNT(*) FILTER (WHERE verification_visit.id IS NOT NULL)::integer AS planned
FROM inspection_findings finding
${joins}
${baseWhere}
`, values) as VerificationPlanningCounters[];
const counters = counterRows[0];
return {
data,
meta: {
page,
pageSize,
total,
totalPages: total === 0 ? 0 : Math.ceil(total / pageSize),
},
counters: {
eligible: Number(counters?.eligible ?? 0),
unplanned: Number(counters?.unplanned ?? 0),
overdueUnplanned: Number(counters?.overdueUnplanned ?? 0),
dueNext30Days: Number(counters?.dueNext30Days ?? 0),
planned: Number(counters?.planned ?? 0),
},
};
}
async recordResult(
visitId: string,
findingId: string,
dto: RecordVerificationResultDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
assertMobileInspector(principal);
const notes = dto.notes.trim();
const verifiedAt = dto.verifiedAt ? new Date(dto.verifiedAt) : new Date();
if (!Number.isFinite(verifiedAt.getTime())) {
throw new BadRequestException({
code: 'VERIFICATION_RESULT_DATE_INVALID',
message: 'La fecha de verificación no es válida',
});
}
if (dto.outcome === InspectionVerificationOutcome.REQUIRES_NEW_DATE && !dto.nextControlOn) {
throw new BadRequestException({
code: 'VERIFICATION_NEW_DATE_REQUIRED',
message: 'Indicá la nueva fecha de verificación',
});
}
if (dto.outcome !== InspectionVerificationOutcome.REQUIRES_NEW_DATE && dto.nextControlOn) {
throw new BadRequestException({
code: 'VERIFICATION_NEW_DATE_NOT_ALLOWED',
message: 'La nueva fecha sólo corresponde cuando el hallazgo requiere un nuevo control',
});
}
return this.dataSource.transaction(async (manager) => {
const [context] = await manager.query(`
SELECT
verification_link.id AS "linkId",
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",
finding.id AS "findingId",
finding.code AS "findingCode",
finding.status AS "findingStatus",
finding.next_control_on AS "findingNextControlOn",
finding.act_id AS "sourceActId",
visit.id AS "visitId",
visit.code AS "visitCode",
visit.status AS "visitStatus"
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE verification_link.visit_id = $1
AND verification_link.finding_id = $2
FOR UPDATE OF verification_link, finding, visit
`, [visitId, findingId]) as Array<{
linkId: string;
targetControlOn: string | null;
outcome: InspectionVerificationOutcome | null;
resultNotes: string | null;
verifiedAt: Date | null;
resultRecordedAt: Date | null;
rescheduledControlOn: string | null;
findingId: string;
findingCode: string;
findingStatus: string;
findingNextControlOn: string | null;
sourceActId: string;
visitId: string;
visitCode: string;
visitStatus: InspectionVisitStatus;
}>;
if (!context) {
throw new NotFoundException({
code: 'VERIFICATION_LINK_NOT_FOUND',
message: 'El hallazgo no pertenece a esta visita de verificación',
});
}
if (context.outcome !== null || context.resultRecordedAt !== null) {
throw new ConflictException({
code: 'VERIFICATION_RESULT_IMMUTABLE',
message: 'El resultado de esta verificación ya fue registrado y no puede reemplazarse',
});
}
if (context.visitStatus !== InspectionVisitStatus.IN_PROGRESS) {
throw new ConflictException({
code: 'VERIFICATION_VISIT_NOT_IN_PROGRESS',
message: 'El resultado sólo puede registrarse durante una visita de verificación en curso',
});
}
if (context.findingStatus !== 'OPEN') {
throw new ConflictException({
code: 'VERIFICATION_FINDING_CLOSED',
message: `${context.findingCode} ya no está abierto`,
});
}
await this.assertActorAssigned(manager, visitId, principal);
let nextControlOn: string | null = context.findingNextControlOn;
let rescheduledControlOn: string | null = null;
if (dto.outcome === InspectionVerificationOutcome.RESOLVED) {
nextControlOn = null;
}
if (dto.outcome === InspectionVerificationOutcome.NOT_RESOLVED) {
nextControlOn = null;
}
if (dto.outcome === InspectionVerificationOutcome.REQUIRES_NEW_DATE) {
rescheduledControlOn = dto.nextControlOn ?? null;
nextControlOn = rescheduledControlOn;
const verifiedDate = verifiedAt.toISOString().slice(0, 10);
if (!rescheduledControlOn || rescheduledControlOn <= verifiedDate) {
throw new BadRequestException({
code: 'VERIFICATION_NEW_DATE_NOT_FUTURE',
message: 'La nueva fecha debe ser posterior a la verificación realizada',
});
}
}
const before = {
outcome: context.outcome,
resultNotes: context.resultNotes,
verifiedAt: context.verifiedAt,
resultRecordedAt: context.resultRecordedAt,
rescheduledControlOn: context.rescheduledControlOn,
findingNextControlOn: context.findingNextControlOn,
};
const recordedAt = new Date();
await manager.query(`
UPDATE inspection_finding_verification_visits
SET outcome = $3,
result_notes = $4,
verified_at = $5,
result_recorded_at = $6,
result_recorded_by = $7,
rescheduled_control_on = $8,
updated_at = CURRENT_TIMESTAMP
WHERE visit_id = $1 AND finding_id = $2
`, [
visitId,
findingId,
dto.outcome,
notes,
verifiedAt,
recordedAt,
principal.userId,
rescheduledControlOn,
]);
await manager.query(`
UPDATE inspection_findings
SET next_control_on = $2,
updated_by = $3,
updated_at = CURRENT_TIMESTAMP
WHERE id = $1
`, [findingId, nextControlOn, principal.userId]);
await appendVerificationEvent(manager, {
findingId,
verificationVisitId: visitId,
eventType: InspectionFindingVerificationEventType.RESULT_RECORDED,
occurredAt: verifiedAt,
targetControlOn: context.targetControlOn,
previousControlOn: context.findingNextControlOn,
nextControlOn,
outcome: dto.outcome,
notes,
}, principal);
await appendVerificationEvent(manager, {
findingId,
verificationVisitId: visitId,
eventType: rescheduledControlOn
? InspectionFindingVerificationEventType.CONTROL_DATE_CHANGED
: InspectionFindingVerificationEventType.CONTROL_DATE_CLEARED,
occurredAt: verifiedAt,
targetControlOn: rescheduledControlOn,
previousControlOn: context.findingNextControlOn,
nextControlOn,
notes: rescheduledControlOn
? 'Nueva fecha definida como resultado de la verificación'
: 'La verificación consumió la fecha prevista sin fijar un nuevo control',
}, principal);
const versionNumber = await this.captureFindingVerificationVersion(
manager,
findingId,
visitId,
dto.outcome,
notes,
verifiedAt,
rescheduledControlOn,
principal,
);
const [result] = await manager.query(`
SELECT
verification_link.finding_id AS "findingId",
verification_link.visit_id AS "visitId",
verification_link.target_control_on AS "targetControlOn",
verification_link.outcome,
verification_link.result_notes AS "notes",
verification_link.verified_at AS "verifiedAt",
verification_link.result_recorded_at AS "recordedAt",
verification_link.rescheduled_control_on AS "rescheduledControlOn",
finding.next_control_on AS "nextControlOn",
finding.status AS "findingStatus",
visit.status AS "visitStatus"
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE verification_link.visit_id = $1 AND verification_link.finding_id = $2
`, [visitId, findingId]) as Array<Record<string, unknown>>;
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VERIFICATION_RESULT_RECORDED,
entityType: 'inspection_finding_verification_visit',
entityId: context.linkId,
beforeData: before,
afterData: result ?? null,
metadata: {
findingId,
sourceActId: context.sourceActId,
visitId,
visitCode: context.visitCode,
versionNumber,
},
}, manager);
return result;
});
}
async planVisit(
dto: PlanVerificationVisitDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const findingIds = [...new Set(dto.findingIds)];
const plannedStartAt = new Date(dto.plannedStartAt);
if (!Number.isFinite(plannedStartAt.getTime())) {
throw new BadRequestException({ code: 'VERIFICATION_START_INVALID', message: 'La fecha prevista de inicio no es válida' });
}
return this.dataSource.transaction(async (manager) => {
const rows = await manager.query(`
SELECT
finding.id,
finding.code,
finding.title,
finding.status,
finding.asset_id AS "assetId",
finding.next_control_on AS "nextControlOn",
asset.code AS "assetCode",
asset.name AS "assetName",
area.id AS "areaId",
area.code AS "areaCode",
area.name AS "areaName"
FROM inspection_findings finding
INNER JOIN assets asset ON asset.id = finding.asset_id
LEFT JOIN assets area ON area.id = asset.operational_area_id
WHERE finding.id = ANY($1::uuid[])
FOR UPDATE OF finding
`, [findingIds]) as LockedFindingRow[];
if (rows.length !== findingIds.length) {
throw new NotFoundException({ code: 'VERIFICATION_FINDING_NOT_FOUND', message: 'Uno o más hallazgos ya no están disponibles' });
}
const invalid = rows.find((row) => row.status !== 'OPEN' || !row.nextControlOn);
if (invalid) {
throw new ConflictException({
code: 'VERIFICATION_FINDING_NOT_READY',
message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`,
});
}
const missingArea = rows.find((row) => !row.areaId);
if (missingArea) {
throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_REQUIRED',
message: `${missingArea.code} necesita un Área física antes de planificar la visita`,
});
}
const areaId = rows[0]?.areaId;
if (!areaId) {
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan un Área física' });
}
if (rows.some((row) => row.areaId !== areaId)) {
throw new BadRequestException({
code: 'VERIFICATION_CONTEXT_MIXED',
message: 'Seleccioná hallazgos de una misma Área para crear una visita de verificación',
});
}
// A verification visit is a new operational act. Its operator is therefore
// the Company valid for the Area at the planned visit date, never the
// historical compatibility snapshot stored on the Inventory.
const [operator] = await manager.query(`
SELECT
company.id AS "companyId",
company.code AS "companyCode",
company.name AS "companyName"
FROM area_company_relations relation
INNER JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=$1::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2::timestamptz
AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz)
AND company.information_status<>'INACTIVE'
ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC
LIMIT 1
FOR SHARE OF relation
`, [areaId, plannedStartAt]) as PlannedOperatorRow[];
if (!operator) {
throw new ConflictException({
code: 'VERIFICATION_OPERATOR_NOT_ACTIVE',
message: 'El Área no tiene una Operadora vigente para la fecha prevista de la verificación',
});
}
const companyId = operator.companyId;
const activeLinks = await manager.query(`
SELECT verification_link.finding_id AS "findingId", visit.code
FROM inspection_finding_verification_visits verification_link
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
WHERE verification_link.finding_id = ANY($1::uuid[])
AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')
`, [findingIds]) as Array<{ findingId: string; code: string }>;
if (activeLinks.length > 0) {
throw new ConflictException({
code: 'VERIFICATION_ALREADY_PLANNED',
message: `Al menos un hallazgo ya está asociado a una visita activa (${activeLinks[0]?.code ?? 'existente'})`,
});
}
const companyName = operator.companyName;
const areaName = rows[0]?.areaName ?? 'Área';
const code = await nextInspectionVisitCode(manager, plannedStartAt);
const findingCodes = rows.map((row) => row.code).join(', ');
const notes = dto.notes?.trim() || null;
const instructions = [
`Verificar ${rows.length} hallazgo${rows.length === 1 ? '' : 's'}: ${findingCodes}.`,
notes,
].filter(Boolean).join('\n\n');
const visit = manager.getRepository(InspectionVisit).create({
code,
objective: 'Verificar en campo la resolución de hallazgos con fecha de control programada.',
status: InspectionVisitStatus.DRAFT,
scopeAssetId: areaId,
operationalAreaId: areaId,
operatorCompanyId: companyId,
leadInspectorUserId: null,
plannedStartAt,
actualStartedAt: null,
actualClosedAt: null,
instructions,
cancellationReason: null,
checklistGeneration: 0,
checklistGeneratedAt: null,
createdBy: principal.userId,
updatedBy: principal.userId,
});
await manager.getRepository(InspectionVisit).save(visit);
const assetIds = [...new Set(rows.map((row) => row.assetId))];
for (const assetId of assetIds) {
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, 'VERIFICATION', NULL, NULL, NULL, $3)
ON CONFLICT (visit_id, asset_id) DO UPDATE SET
included = true,
planning_source = 'VERIFICATION',
exclusion_reason = NULL,
excluded_by = NULL,
excluded_at = NULL,
added_by = EXCLUDED.added_by,
updated_at = CURRENT_TIMESTAMP
`, [visit.id, assetId, principal.userId]);
await manager.query(`
INSERT INTO inspection_visit_asset_events (
visit_id, asset_id, event_type, actor_user_id, metadata
) VALUES ($1, $2, 'VERIFICATION_INCLUDED', $3, $4::jsonb)
`, [visit.id, assetId, principal.userId, JSON.stringify({ findingIds })]);
}
for (const row of rows) {
await manager.query(`
INSERT INTO inspection_finding_verification_visits (
finding_id, visit_id, linked_by, target_control_on
) VALUES ($1, $2, $3, $4)
ON CONFLICT (finding_id, visit_id) DO NOTHING
`, [row.id, visit.id, principal.userId, row.nextControlOn]);
await appendVerificationEvent(manager, {
findingId: row.id,
verificationVisitId: visit.id,
eventType: InspectionFindingVerificationEventType.VISIT_PLANNED,
occurredAt: new Date(),
targetControlOn: row.nextControlOn,
previousControlOn: row.nextControlOn,
nextControlOn: row.nextControlOn,
notes: `Visita ${visit.code} planificada para verificar ${row.code}`,
}, principal);
}
await this.audit.record({
...administrationAuditContext(principal, request),
action: AuditAction.INSPECTION_VERIFICATION_PLANNED,
entityType: 'inspection_visit',
entityId: visit.id,
afterData: {
code: visit.code,
status: visit.status,
plannedStartAt: visit.plannedStartAt,
companyId,
areaId,
findingIds,
assetIds,
operatorSource: 'area_company_relations',
},
}, manager);
return {
visit: {
id: visit.id,
code: visit.code,
status: visit.status,
plannedStartAt: visit.plannedStartAt,
},
company: { id: companyId, code: operator.companyCode, name: companyName },
area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName },
findingCount: findingIds.length,
assetCount: assetIds.length,
};
});
}
private async assertActorAssigned(
manager: EntityManager,
visitId: string,
principal: AuthPrincipal,
): Promise<void> {
const rows = await manager.query(`
SELECT 1
FROM inspection_visit_members
WHERE visit_id = $1 AND user_id = $2 AND included = true
`, [visitId, principal.userId]) as unknown[];
if (rows.length === 0) {
throw new ConflictException({
code: 'VERIFICATION_INSPECTOR_NOT_ASSIGNED',
message: 'La visita de verificación no está asignada al inspector actual',
});
}
}
private async captureFindingVerificationVersion(
manager: EntityManager,
findingId: string,
verificationVisitId: string,
outcome: InspectionVerificationOutcome,
notes: string,
verifiedAt: Date,
rescheduledControlOn: string | null,
principal: AuthPrincipal,
): Promise<number> {
const [updated] = await manager.query(`
UPDATE inspection_findings
SET current_version = current_version + 1
WHERE id = $1
RETURNING current_version AS "versionNumber"
`, [findingId]) as Array<{ versionNumber: number }>;
const versionNumber = Number(updated?.versionNumber ?? 0);
const [snapshot] = await manager.query(`
SELECT JSONB_BUILD_OBJECT(
'id', finding.id,
'code', finding.code,
'findingNumber', finding.finding_number,
'status', finding.status,
'title', finding.title,
'description', finding.description,
'correctionDueOn', finding.correction_due_on,
'nextControlOn', finding.next_control_on,
'currentVersion', finding.current_version,
'verification', JSONB_BUILD_OBJECT(
'visitId', verification_visit.id,
'visitCode', verification_visit.code,
'outcome', $3::text,
'notes', $4::text,
'verifiedAt', $5::timestamptz,
'rescheduledControlOn', $6::date
),
'act', JSONB_BUILD_OBJECT('id', act.id, 'code', act.code, 'status', act.status),
'sourceVisit', JSONB_BUILD_OBJECT('id', source_visit.id, 'code', source_visit.code, 'status', source_visit.status),
'asset', JSONB_BUILD_OBJECT('id', asset.id, 'code', asset.code, 'name', asset.name)
) AS snapshot
FROM inspection_findings finding
INNER JOIN inspection_acts act ON act.id = finding.act_id
INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id
INNER JOIN assets asset ON asset.id = finding.asset_id
INNER JOIN inspection_visits verification_visit ON verification_visit.id = $2
WHERE finding.id = $1
`, [findingId, verificationVisitId, outcome, notes, verifiedAt, rescheduledControlOn]) as Array<{
snapshot: Record<string, unknown>;
}>;
await manager.query(`
INSERT INTO inspection_finding_versions (
finding_id, version_number, event, snapshot, actor_user_id, actor_username
) VALUES ($1, $2, $3, $4, $5, $6)
`, [
findingId,
versionNumber,
InspectionFindingVersionEvent.VERIFICATION_RECORDED,
snapshot?.snapshot ?? {},
principal.userId,
principal.username,
]);
return versionNumber;
}
}