chore: import DH V2 D5.6.4 production baseline
This commit is contained in:
@@ -0,0 +1,763 @@
|
||||
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;
|
||||
title: string;
|
||||
status: InspectionVisitStatus;
|
||||
plannedStartAt: Date | null;
|
||||
plannedEndAt: Date | null;
|
||||
}
|
||||
|
||||
export interface VerificationPlanningItem {
|
||||
id: string;
|
||||
code: string;
|
||||
title: string;
|
||||
status: 'OPEN';
|
||||
nextControlOn: string;
|
||||
companyResponseReceivedOn: string;
|
||||
asset: VerificationContextView & { typeName: string };
|
||||
company: VerificationContextView | null;
|
||||
area: VerificationContextView | null;
|
||||
act: { id: string; code: string };
|
||||
sourceVisit: { id: string; code: string; title: 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;
|
||||
companyResponseReceivedOn: 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.company_response_received_on IS NOT NULL`,
|
||||
`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.title,
|
||||
visit.status,
|
||||
visit.planned_start_at AS "plannedStartAt",
|
||||
visit.planned_end_at AS "plannedEndAt"
|
||||
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",
|
||||
finding.company_response_received_on AS "companyResponseReceivedOn",
|
||||
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,
|
||||
'title', source_visit.title
|
||||
) AS "sourceVisit",
|
||||
CASE WHEN verification_visit.id IS NULL THEN NULL ELSE jsonb_build_object(
|
||||
'id', verification_visit.id,
|
||||
'code', verification_visit.code,
|
||||
'title', verification_visit.title,
|
||||
'status', verification_visit.status,
|
||||
'plannedStartAt', verification_visit."plannedStartAt",
|
||||
'plannedEndAt', verification_visit."plannedEndAt"
|
||||
) 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.company_response_received_on AS "companyResponseReceivedOn",
|
||||
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.companyResponseReceivedOn || !row.nextControlOn,
|
||||
);
|
||||
if (invalid) {
|
||||
throw new ConflictException({
|
||||
code: 'VERIFICATION_FINDING_NOT_READY',
|
||||
message: `${invalid.code} no está listo para una verificación planificada`,
|
||||
});
|
||||
}
|
||||
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 title = code;
|
||||
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,
|
||||
title,
|
||||
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,
|
||||
plannedEndAt: null,
|
||||
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,
|
||||
plannedEndAt: visit.plannedEndAt,
|
||||
companyId,
|
||||
areaId,
|
||||
findingIds,
|
||||
assetIds,
|
||||
},
|
||||
}, manager);
|
||||
|
||||
return {
|
||||
visit: {
|
||||
id: visit.id,
|
||||
code: visit.code,
|
||||
title: visit.title,
|
||||
status: visit.status,
|
||||
plannedStartAt: visit.plannedStartAt,
|
||||
plannedEndAt: visit.plannedEndAt,
|
||||
},
|
||||
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,
|
||||
'companyResponse', finding.company_response,
|
||||
'companyResponseReceivedOn', finding.company_response_received_on,
|
||||
'companyCommittedCorrectionOn', finding.company_committed_correction_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;
|
||||
}
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user