740 lines
28 KiB
TypeScript
740 lines
28 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;
|
|
companyId: string | null;
|
|
companyCode: string | null;
|
|
companyName: string | null;
|
|
areaId: string | null;
|
|
areaCode: string | null;
|
|
areaName: string | null;
|
|
nextControlOn: string | null;
|
|
status: 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 company ON company.id = asset.operator_company_id
|
|
LEFT JOIN assets area ON area.id = asset.operational_area_id
|
|
${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",
|
|
company.id AS "companyId",
|
|
company.code AS "companyCode",
|
|
company.name AS "companyName",
|
|
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 company ON company.id = asset.operator_company_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 missingContext = rows.find((row) => !row.companyId || !row.areaId);
|
|
if (missingContext) {
|
|
throw new BadRequestException({
|
|
code: 'VERIFICATION_CONTEXT_REQUIRED',
|
|
message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`,
|
|
});
|
|
}
|
|
const companyId = rows[0]?.companyId;
|
|
const areaId = rows[0]?.areaId;
|
|
if (!companyId || !areaId) {
|
|
throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' });
|
|
}
|
|
if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) {
|
|
throw new BadRequestException({
|
|
code: 'VERIFICATION_CONTEXT_MIXED',
|
|
message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación',
|
|
});
|
|
}
|
|
|
|
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 = rows[0]?.companyName ?? 'Empresa';
|
|
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,
|
|
},
|
|
}, manager);
|
|
|
|
return {
|
|
visit: {
|
|
id: visit.id,
|
|
code: visit.code,
|
|
status: visit.status,
|
|
plannedStartAt: visit.plannedStartAt,
|
|
},
|
|
company: { id: companyId, code: rows[0]?.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;
|
|
}
|
|
}
|