1263 lines
52 KiB
TypeScript
1263 lines
52 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
ForbiddenException,
|
|
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,
|
|
InspectionAct,
|
|
InspectionActStatus,
|
|
InspectionFinding,
|
|
InspectionFindingResponseDueBasis,
|
|
InspectionFindingStatus,
|
|
InspectionFindingVerificationEventType,
|
|
InspectionFindingVersionEvent,
|
|
InspectionVerificationOutcome,
|
|
InspectionVisit,
|
|
InspectionVisitStatus,
|
|
} from '../database/entities';
|
|
import type { CloseInspectionFindingDto } from './dto/close-inspection-finding.dto';
|
|
import type { CreateInspectionFindingDto } from './dto/create-inspection-finding.dto';
|
|
import type { ListInspectionFindingsQueryDto } from './dto/list-inspection-findings-query.dto';
|
|
import type { UpdateInspectionFindingFollowUpDto } from './dto/update-inspection-finding-follow-up.dto';
|
|
import type { UpdateInspectionFindingDto } from './dto/update-inspection-finding.dto';
|
|
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
|
import { appendVerificationEvent } from '../inspection-verifications/verification-event-ledger';
|
|
|
|
interface FindingContextView {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
}
|
|
|
|
interface FindingAssetView {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
commonName: string | null;
|
|
typeName: string;
|
|
operatorCompany: FindingContextView | null;
|
|
operationalArea: FindingContextView | null;
|
|
}
|
|
|
|
type FindingCatalogView = {
|
|
id: string;
|
|
code: string;
|
|
categoryId: string;
|
|
categoryName: string;
|
|
sourceNumber: number;
|
|
revision: number;
|
|
suggestedSeverity: number | null;
|
|
} | null;
|
|
|
|
interface FindingDocumentView {
|
|
actId: string;
|
|
actCode: string;
|
|
actStatus: InspectionActStatus;
|
|
visitId: string;
|
|
visitCode: string;
|
|
visitStatus: InspectionVisitStatus;
|
|
}
|
|
|
|
export interface InspectionFindingListItem {
|
|
id: string;
|
|
actId: string;
|
|
assetId: string;
|
|
catalogItemId: string | null;
|
|
findingNumber: number;
|
|
code: string;
|
|
status: InspectionFindingStatus;
|
|
title: string;
|
|
description: string;
|
|
legalBasis: string | null;
|
|
glossary: string | null;
|
|
catalogRevision: number | null;
|
|
suggestedSeverity: number | null;
|
|
severity: number | null;
|
|
correctionDueOn: string | null;
|
|
responseDueBasis: InspectionFindingResponseDueBasis | null;
|
|
responseDueDays: number | null;
|
|
responseDueBaseOn: string | null;
|
|
reportNotifiedOn: string | null;
|
|
companyResponse: string | null;
|
|
companyResponseReceivedOn: string | null;
|
|
companyCommittedCorrectionOn: string | null;
|
|
nextControlOn: string | null;
|
|
currentVersion: number;
|
|
closedAt: Date | null;
|
|
closureNotes: string | null;
|
|
asset: FindingAssetView;
|
|
catalog: FindingCatalogView;
|
|
document: FindingDocumentView;
|
|
verificationVisit: {
|
|
id: string;
|
|
code: string;
|
|
status: InspectionVisitStatus;
|
|
plannedStartAt: Date | null;
|
|
} | null;
|
|
latestVerification: {
|
|
visitId: string;
|
|
visitCode: string;
|
|
visitStatus: InspectionVisitStatus;
|
|
targetControlOn: string | null;
|
|
outcome: 'RESOLVED' | 'NOT_RESOLVED' | 'REQUIRES_NEW_DATE' | null;
|
|
resultNotes: string | null;
|
|
verifiedAt: Date | null;
|
|
resultRecordedAt: Date | null;
|
|
rescheduledControlOn: string | null;
|
|
evidenceCount: number;
|
|
} | null;
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
}
|
|
|
|
interface FindingVersionView {
|
|
id: string;
|
|
versionNumber: number;
|
|
event: InspectionFindingVersionEvent;
|
|
snapshot: Record<string, unknown>;
|
|
actorUserId: string | null;
|
|
actorUsername: string | null;
|
|
createdAt: Date;
|
|
}
|
|
|
|
export interface FindingVerificationHistoryEvent {
|
|
id: string;
|
|
eventType: InspectionFindingVerificationEventType;
|
|
occurredAt: Date;
|
|
targetControlOn: string | null;
|
|
previousControlOn: string | null;
|
|
nextControlOn: string | null;
|
|
outcome: InspectionVerificationOutcome | null;
|
|
notes: string | null;
|
|
actorUserId: string | null;
|
|
actorUsername: string | null;
|
|
verificationVisit: {
|
|
id: string;
|
|
code: string;
|
|
status: InspectionVisitStatus;
|
|
plannedStartAt: Date | null;
|
|
} | null;
|
|
evidenceCount: number;
|
|
}
|
|
|
|
export type InspectionFindingOfficeStage =
|
|
| 'WAITING_COMPANY'
|
|
| 'COMPANY_OVERDUE'
|
|
| 'DEFINE_VERIFICATION'
|
|
| 'PLAN_VERIFICATION'
|
|
| 'VERIFICATION_PLANNED'
|
|
| 'RESCHEDULE_VERIFICATION'
|
|
| 'READY_TO_CLOSE'
|
|
| 'CLOSED';
|
|
|
|
export interface InspectionFindingOfficeWorkflow {
|
|
stage: InspectionFindingOfficeStage;
|
|
label: string;
|
|
action: string;
|
|
dueOn: string | null;
|
|
overdue: boolean;
|
|
}
|
|
|
|
export interface InspectionFindingView extends InspectionFindingListItem {
|
|
versions: FindingVersionView[];
|
|
verificationHistory: FindingVerificationHistoryEvent[];
|
|
officeWorkflow: InspectionFindingOfficeWorkflow;
|
|
}
|
|
|
|
|
|
export interface InspectionFindingWorkflowCounters {
|
|
open: number;
|
|
waitingCompany: number;
|
|
companyOverdue: number;
|
|
companyDueNext7Days: number;
|
|
awaitingVerificationSchedule: number;
|
|
toVerify: number;
|
|
verificationOverdue: number;
|
|
verificationNext30Days: number;
|
|
readyToClose: number;
|
|
closed: number;
|
|
}
|
|
|
|
export interface InspectionFindingGlobalPage {
|
|
data: InspectionFindingListItem[];
|
|
meta: { page: number; pageSize: number; total: number; totalPages: number };
|
|
counters: InspectionFindingWorkflowCounters;
|
|
}
|
|
|
|
interface CatalogSnapshot {
|
|
id: string;
|
|
title: string;
|
|
legalBasis: string | null;
|
|
glossary: string | null;
|
|
revision: number;
|
|
suggestedSeverity: number | null;
|
|
}
|
|
|
|
function findingNotFound(): NotFoundException {
|
|
return new NotFoundException({
|
|
code: 'INSPECTION_FINDING_NOT_FOUND',
|
|
message: 'Hallazgo de inspección no encontrado',
|
|
});
|
|
}
|
|
|
|
function actNotFound(): NotFoundException {
|
|
return new NotFoundException({
|
|
code: 'INSPECTION_ACT_NOT_FOUND',
|
|
message: 'Acta de inspección no encontrada',
|
|
});
|
|
}
|
|
|
|
function addCalendarDays(dateOnly: string, days: number): string {
|
|
const [year, month, day] = dateOnly.split('-').map(Number);
|
|
const date = new Date(Date.UTC(year, month - 1, day));
|
|
date.setUTCDate(date.getUTCDate() + days);
|
|
return date.toISOString().slice(0, 10);
|
|
}
|
|
|
|
@Injectable()
|
|
export class InspectionFindingsService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly audit: AuditService,
|
|
) {}
|
|
|
|
async listForAct(actId: string): Promise<{ data: InspectionFindingListItem[] }> {
|
|
await this.requireAct(this.dataSource.manager, actId);
|
|
const data = await this.dataSource.query(
|
|
`${this.findingSelect('WHERE finding.act_id = $1')}
|
|
ORDER BY finding.finding_number, finding.id`,
|
|
[actId],
|
|
) as InspectionFindingListItem[];
|
|
return { data };
|
|
}
|
|
|
|
|
|
async listGlobal(query: ListInspectionFindingsQueryDto): Promise<InspectionFindingGlobalPage> {
|
|
const page = query.page ?? 1;
|
|
const pageSize = query.pageSize ?? 25;
|
|
const workflow = query.workflow ?? 'OPEN';
|
|
const values: unknown[] = [];
|
|
const filters: string[] = [];
|
|
const today = `(CURRENT_TIMESTAMP AT TIME ZONE 'America/Argentina/Mendoza')::date`;
|
|
const latestVerificationOutcome = `(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)`;
|
|
const latestVerificationVisitStatus = `(SELECT latest_visit.status
|
|
FROM inspection_finding_verification_visits latest_verification
|
|
INNER JOIN inspection_visits latest_visit ON latest_visit.id = latest_verification.visit_id
|
|
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)`;
|
|
|
|
if (query.search) {
|
|
values.push(`%${query.search}%`);
|
|
const parameter = `$${values.length}`;
|
|
filters.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) {
|
|
values.push(query.companyId);
|
|
filters.push(`company.id = $${values.length}::uuid`);
|
|
}
|
|
if (query.areaId) {
|
|
values.push(query.areaId);
|
|
filters.push(`area.id = $${values.length}::uuid`);
|
|
}
|
|
if (query.inspectorId) {
|
|
values.push(query.inspectorId);
|
|
const inspector = `$${values.length}`;
|
|
filters.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) {
|
|
values.push(query.dateFrom);
|
|
filters.push(`act.occurred_at::date >= $${values.length}::date`);
|
|
}
|
|
if (query.dateTo) {
|
|
values.push(query.dateTo);
|
|
filters.push(`act.occurred_at::date <= $${values.length}::date`);
|
|
}
|
|
|
|
if (workflow === 'OPEN') filters.push(`finding.status = 'OPEN'`);
|
|
if (workflow === 'WAITING_COMPANY') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NULL`);
|
|
if (workflow === 'COMPANY_OVERDUE') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NULL AND finding.correction_due_on IS NOT NULL AND finding.correction_due_on < ${today}`);
|
|
if (workflow === 'TO_SCHEDULE_VERIFICATION') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NULL AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`);
|
|
if (workflow === 'TO_VERIFY') filters.push(`finding.status = 'OPEN' AND finding.company_response_received_on IS NOT NULL AND finding.next_control_on IS NOT NULL AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`);
|
|
if (workflow === 'VERIFICATION_OVERDUE') filters.push(`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 < ${today} AND COALESCE(${latestVerificationOutcome}, '') <> 'RESOLVED'`);
|
|
if (workflow === 'READY_TO_CLOSE') filters.push(`finding.status = 'OPEN' AND ${latestVerificationOutcome} = 'RESOLVED' AND ${latestVerificationVisitStatus} = 'CLOSED'`);
|
|
if (workflow === 'CLOSED') filters.push(`finding.status = 'CLOSED'`);
|
|
|
|
const where = filters.length ? `WHERE ${filters.join(' AND ')}` : '';
|
|
const countRows = await this.dataSource.query(`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM inspection_findings finding
|
|
INNER JOIN inspection_acts act ON act.id = finding.act_id
|
|
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
|
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}
|
|
`, values) as Array<{ total: number }>;
|
|
const total = Number(countRows[0]?.total ?? 0);
|
|
values.push(pageSize);
|
|
const limitParameter = `$${values.length}`;
|
|
values.push((page - 1) * pageSize);
|
|
const offsetParameter = `$${values.length}`;
|
|
const data = await this.dataSource.query(
|
|
`${this.findingSelect(where)}
|
|
ORDER BY
|
|
CASE WHEN finding.status = 'OPEN' THEN 0 ELSE 1 END,
|
|
COALESCE(finding.next_control_on, finding.correction_due_on, '9999-12-31'::date),
|
|
finding.updated_at DESC,
|
|
finding.code
|
|
LIMIT ${limitParameter} OFFSET ${offsetParameter}`,
|
|
values,
|
|
) as InspectionFindingListItem[];
|
|
const [counters] = await this.dataSource.query(`
|
|
SELECT
|
|
COUNT(*) FILTER (WHERE status = 'OPEN')::integer AS "open",
|
|
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL)::integer AS "waitingCompany",
|
|
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL AND correction_due_on IS NOT NULL AND correction_due_on < ${today})::integer AS "companyOverdue",
|
|
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NULL AND correction_due_on BETWEEN ${today} AND ${today} + 7)::integer AS "companyDueNext7Days",
|
|
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NULL AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "awaitingVerificationSchedule",
|
|
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NOT NULL AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "toVerify",
|
|
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on IS NOT NULL AND next_control_on < ${today} AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "verificationOverdue",
|
|
COUNT(*) FILTER (WHERE status = 'OPEN' AND company_response_received_on IS NOT NULL AND next_control_on BETWEEN ${today} AND ${today} + 30 AND COALESCE((SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1), '') <> 'RESOLVED')::integer AS "verificationNext30Days",
|
|
COUNT(*) FILTER (WHERE status = 'OPEN' AND (SELECT v.outcome FROM inspection_finding_verification_visits v WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1) = 'RESOLVED' AND (SELECT vv.status FROM inspection_finding_verification_visits v INNER JOIN inspection_visits vv ON vv.id = v.visit_id WHERE v.finding_id = inspection_findings.id AND v.outcome IS NOT NULL ORDER BY v.result_recorded_at DESC NULLS LAST, v.created_at DESC, v.id DESC LIMIT 1) = 'CLOSED')::integer AS "readyToClose",
|
|
COUNT(*) FILTER (WHERE status = 'CLOSED')::integer AS "closed"
|
|
FROM inspection_findings
|
|
`) as InspectionFindingWorkflowCounters[];
|
|
return {
|
|
data,
|
|
meta: {
|
|
page,
|
|
pageSize,
|
|
total,
|
|
totalPages: Math.ceil(total / pageSize),
|
|
},
|
|
counters: {
|
|
open: Number(counters?.open ?? 0),
|
|
waitingCompany: Number(counters?.waitingCompany ?? 0),
|
|
companyOverdue: Number(counters?.companyOverdue ?? 0),
|
|
companyDueNext7Days: Number(counters?.companyDueNext7Days ?? 0),
|
|
awaitingVerificationSchedule: Number(counters?.awaitingVerificationSchedule ?? 0),
|
|
toVerify: Number(counters?.toVerify ?? 0),
|
|
verificationOverdue: Number(counters?.verificationOverdue ?? 0),
|
|
verificationNext30Days: Number(counters?.verificationNext30Days ?? 0),
|
|
readyToClose: Number(counters?.readyToClose ?? 0),
|
|
closed: Number(counters?.closed ?? 0),
|
|
},
|
|
};
|
|
}
|
|
|
|
async getById(id: string): Promise<InspectionFindingView> {
|
|
return this.dataSource.transaction((manager) => this.loadView(manager, id));
|
|
}
|
|
|
|
async getVerificationHistory(id: string): Promise<FindingVerificationHistoryEvent[]> {
|
|
const [exists] = await this.dataSource.query('SELECT id FROM inspection_findings WHERE id = $1', [id]) as Array<{ id: string }>;
|
|
if (!exists) throw findingNotFound();
|
|
return this.loadVerificationHistory(this.dataSource.manager, id);
|
|
}
|
|
|
|
async create(
|
|
actId: string,
|
|
dto: CreateInspectionFindingDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionFindingView> {
|
|
assertMobileInspector(principal);
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const act = await this.lockAct(manager, actId);
|
|
const visit = await this.lockVisit(manager, act.visitId);
|
|
this.assertActEditable(act, visit);
|
|
await this.assertActorAssigned(manager, visit.id, principal);
|
|
await this.assertActAsset(manager, actId, dto.assetId);
|
|
|
|
const catalog = dto.catalogItemId
|
|
? await this.requireCatalogItem(manager, dto.catalogItemId, dto.assetId)
|
|
: null;
|
|
if (!catalog && !dto.customTitle?.trim()) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_FINDING_TITLE_REQUIRED',
|
|
message: 'Un hallazgo personalizado debe indicar un título',
|
|
});
|
|
}
|
|
|
|
const findingNumber = await this.allocateFindingNumber(manager, actId);
|
|
if (findingNumber > 999) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_FINDING_SEQUENCE_EXHAUSTED',
|
|
message: 'El acta alcanzó el máximo de 999 hallazgos',
|
|
});
|
|
}
|
|
const finding = manager.getRepository(InspectionFinding).create({
|
|
actId,
|
|
assetId: dto.assetId,
|
|
catalogItemId: catalog?.id ?? null,
|
|
findingNumber,
|
|
code: `${act.code}-H${String(findingNumber).padStart(3, '0')}`,
|
|
status: InspectionFindingStatus.OPEN,
|
|
title: catalog?.title ?? dto.customTitle!.trim(),
|
|
description: dto.description,
|
|
legalBasis: catalog?.legalBasis ?? dto.customLegalBasis ?? null,
|
|
glossary: catalog?.glossary ?? null,
|
|
catalogRevision: catalog?.revision ?? null,
|
|
suggestedSeverity: catalog?.suggestedSeverity ?? null,
|
|
severity: dto.severity ?? catalog?.suggestedSeverity ?? null,
|
|
correctionDueOn: dto.correctionDueOn ?? null,
|
|
companyResponse: null,
|
|
companyResponseReceivedOn: null,
|
|
companyCommittedCorrectionOn: null,
|
|
nextControlOn: null,
|
|
currentVersion: 0,
|
|
closedAt: null,
|
|
closedBy: null,
|
|
closureNotes: null,
|
|
createdBy: principal.userId,
|
|
updatedBy: principal.userId,
|
|
});
|
|
await manager.getRepository(InspectionFinding).save(finding);
|
|
if (!catalog) {
|
|
await manager.query(`
|
|
INSERT INTO finding_catalog_proposals (
|
|
finding_id, asset_id, asset_type_id, proposed_title, proposed_legal_basis,
|
|
proposed_severity, description, status, created_by
|
|
)
|
|
SELECT $1, asset.id, asset.asset_type_id, $2, $3, $4, $5, 'PENDING', $6
|
|
FROM assets asset
|
|
WHERE asset.id = $7
|
|
ON CONFLICT (finding_id) DO NOTHING
|
|
`, [
|
|
finding.id,
|
|
finding.title,
|
|
finding.legalBasis,
|
|
finding.severity,
|
|
finding.description,
|
|
principal.userId,
|
|
finding.assetId,
|
|
]);
|
|
}
|
|
const versionNumber = await this.captureVersion(
|
|
manager,
|
|
finding,
|
|
InspectionFindingVersionEvent.CREATED,
|
|
principal,
|
|
);
|
|
const created = await this.loadView(manager, finding.id);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_FINDING_CREATED,
|
|
entityType: 'inspection_finding',
|
|
entityId: finding.id,
|
|
afterData: this.auditView(created),
|
|
metadata: { actId, visitId: visit.id, versionNumber },
|
|
}, manager);
|
|
return created;
|
|
});
|
|
}
|
|
|
|
async update(
|
|
id: string,
|
|
dto: UpdateInspectionFindingDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionFindingView> {
|
|
assertMobileInspector(principal);
|
|
if (Object.keys(dto).length === 0) {
|
|
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
|
}
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const finding = await this.lockFinding(manager, id);
|
|
const act = await this.lockAct(manager, finding.actId);
|
|
const visit = await this.lockVisit(manager, act.visitId);
|
|
this.assertActEditable(act, visit);
|
|
this.assertFindingOpen(finding);
|
|
await this.assertActorAssigned(manager, visit.id, principal);
|
|
if (dto.assetId !== undefined) {
|
|
await this.assertActAsset(manager, act.id, dto.assetId);
|
|
if (finding.catalogItemId) {
|
|
await this.requireCatalogItem(manager, finding.catalogItemId, dto.assetId);
|
|
}
|
|
}
|
|
const before = await this.loadView(manager, id);
|
|
if (dto.assetId !== undefined) {
|
|
finding.assetId = dto.assetId;
|
|
if (!finding.catalogItemId) {
|
|
await manager.query(`
|
|
UPDATE finding_catalog_proposals proposal SET
|
|
asset_id = asset.id,
|
|
asset_type_id = asset.asset_type_id,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
FROM assets asset
|
|
WHERE proposal.finding_id = $1
|
|
AND proposal.status = 'PENDING'
|
|
AND asset.id = $2
|
|
`, [finding.id, dto.assetId]);
|
|
}
|
|
}
|
|
if (dto.description !== undefined) finding.description = dto.description;
|
|
if (dto.severity !== undefined) finding.severity = dto.severity;
|
|
if (dto.correctionDueOn !== undefined) finding.correctionDueOn = dto.correctionDueOn;
|
|
finding.updatedBy = principal.userId;
|
|
await manager.getRepository(InspectionFinding).save(finding);
|
|
const versionNumber = await this.captureVersion(
|
|
manager,
|
|
finding,
|
|
InspectionFindingVersionEvent.UPDATED,
|
|
principal,
|
|
);
|
|
const updated = await this.loadView(manager, id);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_FINDING_UPDATED,
|
|
entityType: 'inspection_finding',
|
|
entityId: id,
|
|
beforeData: this.auditView(before),
|
|
afterData: this.auditView(updated),
|
|
metadata: { actId: act.id, visitId: visit.id, versionNumber },
|
|
}, manager);
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
|
|
async close(
|
|
id: string,
|
|
dto: CloseInspectionFindingDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionFindingView> {
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const finding = await this.lockFinding(manager, id);
|
|
this.assertFindingOpen(finding);
|
|
const [activeVerification] = await manager.query(`
|
|
SELECT visit.code, visit.status
|
|
FROM inspection_finding_verification_visits verification_link
|
|
INNER JOIN inspection_visits visit ON visit.id = verification_link.visit_id
|
|
WHERE verification_link.finding_id = $1
|
|
AND visit.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')
|
|
ORDER BY verification_link.created_at DESC
|
|
LIMIT 1
|
|
`, [id]) as Array<{ code: string; status: InspectionVisitStatus }>;
|
|
if (activeVerification) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_FINDING_VERIFICATION_ACTIVE',
|
|
message: `El hallazgo tiene una verificación activa (${activeVerification.code}) y no puede cerrarse todavía`,
|
|
});
|
|
}
|
|
const before = await this.loadView(manager, id);
|
|
finding.status = InspectionFindingStatus.CLOSED;
|
|
finding.closedAt = new Date();
|
|
finding.closedBy = principal.userId;
|
|
finding.closureNotes = dto.closureNotes;
|
|
finding.updatedBy = principal.userId;
|
|
await manager.getRepository(InspectionFinding).save(finding);
|
|
const versionNumber = await this.captureVersion(
|
|
manager,
|
|
finding,
|
|
InspectionFindingVersionEvent.FOLLOW_UP_UPDATED,
|
|
principal,
|
|
);
|
|
const updated = await this.loadView(manager, id);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_FINDING_CLOSED,
|
|
entityType: 'inspection_finding',
|
|
entityId: id,
|
|
beforeData: this.auditView(before),
|
|
afterData: this.auditView(updated),
|
|
metadata: {
|
|
actId: updated.document.actId,
|
|
visitId: updated.document.visitId,
|
|
versionNumber,
|
|
},
|
|
}, manager);
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
async updateFollowUp(
|
|
id: string,
|
|
dto: UpdateInspectionFindingFollowUpDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionFindingView> {
|
|
if (Object.keys(dto).length === 0) {
|
|
throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' });
|
|
}
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const finding = await this.lockFinding(manager, id);
|
|
this.assertFindingOpen(finding);
|
|
const before = await this.loadView(manager, id);
|
|
|
|
if (finding.companyResponseReceivedOn && (dto.companyResponse !== undefined || dto.companyResponseReceivedOn !== undefined)) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_FINDING_RESPONSE_IMMUTABLE',
|
|
message: 'La primera respuesta registrada no se modifica. Las nuevas presentaciones deben agregarse a la trazabilidad.',
|
|
});
|
|
}
|
|
|
|
const deadlineTouched = dto.responseDueBasis !== undefined
|
|
|| dto.responseDueDays !== undefined
|
|
|| dto.reportNotifiedOn !== undefined;
|
|
if (deadlineTouched && finding.companyResponseReceivedOn) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_FINDING_RESPONSE_DEADLINE_LOCKED',
|
|
message: 'El plazo administrativo ya no puede modificarse después de registrar la respuesta de la empresa',
|
|
});
|
|
}
|
|
if (deadlineTouched) {
|
|
const basis = dto.responseDueBasis !== undefined ? dto.responseDueBasis : finding.responseDueBasis;
|
|
const days = dto.responseDueDays !== undefined ? dto.responseDueDays : finding.responseDueDays;
|
|
const reportNotifiedOn = dto.reportNotifiedOn !== undefined ? dto.reportNotifiedOn : finding.reportNotifiedOn;
|
|
if (!basis || days === null || days === undefined) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_FINDING_RESPONSE_DEADLINE_INCOMPLETE',
|
|
message: 'Definí el origen del plazo y la cantidad de días para calcular el vencimiento de respuesta',
|
|
});
|
|
}
|
|
let baseOn: string;
|
|
if (basis === InspectionFindingResponseDueBasis.FINDING_DATE) {
|
|
const [row] = await manager.query(`
|
|
SELECT act.occurred_at::date::text AS "findingDate"
|
|
FROM inspection_acts act
|
|
WHERE act.id = $1
|
|
`, [finding.actId]) as Array<{ findingDate: string }>;
|
|
if (!row?.findingDate) throw actNotFound();
|
|
baseOn = row.findingDate;
|
|
} else {
|
|
if (!reportNotifiedOn) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_FINDING_REPORT_NOTIFICATION_REQUIRED',
|
|
message: 'Indicá la fecha en que el informe fue notificado para iniciar el plazo de respuesta',
|
|
});
|
|
}
|
|
baseOn = reportNotifiedOn;
|
|
}
|
|
finding.responseDueBasis = basis;
|
|
finding.responseDueDays = days;
|
|
finding.reportNotifiedOn = reportNotifiedOn ?? null;
|
|
finding.responseDueBaseOn = baseOn;
|
|
finding.correctionDueOn = addCalendarDays(baseOn, days);
|
|
}
|
|
|
|
const response = dto.companyResponse !== undefined
|
|
? dto.companyResponse
|
|
: finding.companyResponse;
|
|
const receivedOn = dto.companyResponseReceivedOn !== undefined
|
|
? dto.companyResponseReceivedOn
|
|
: finding.companyResponseReceivedOn;
|
|
const committedOn = dto.companyCommittedCorrectionOn !== undefined
|
|
? dto.companyCommittedCorrectionOn
|
|
: finding.companyCommittedCorrectionOn;
|
|
if ((response === null) !== (receivedOn === null)) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_FINDING_RESPONSE_INCOMPLETE',
|
|
message: 'La respuesta de la empresa debe incluir texto y fecha de recepción',
|
|
});
|
|
}
|
|
if (committedOn && !receivedOn) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_FINDING_COMMITMENT_WITHOUT_RESPONSE',
|
|
message: 'La fecha prometida requiere registrar primero la respuesta de la empresa',
|
|
});
|
|
}
|
|
if (committedOn && receivedOn && committedOn < receivedOn) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_FINDING_COMMITMENT_BEFORE_RESPONSE',
|
|
message: 'La fecha prometida no puede ser anterior a la respuesta recibida',
|
|
});
|
|
}
|
|
|
|
if (dto.companyResponse !== undefined) finding.companyResponse = dto.companyResponse;
|
|
if (dto.companyResponseReceivedOn !== undefined) {
|
|
finding.companyResponseReceivedOn = dto.companyResponseReceivedOn;
|
|
}
|
|
if (dto.companyCommittedCorrectionOn !== undefined) {
|
|
finding.companyCommittedCorrectionOn = dto.companyCommittedCorrectionOn;
|
|
}
|
|
if (dto.nextControlOn !== undefined) finding.nextControlOn = dto.nextControlOn;
|
|
finding.updatedBy = principal.userId;
|
|
await manager.getRepository(InspectionFinding).save(finding);
|
|
if (dto.nextControlOn !== undefined && dto.nextControlOn !== before.nextControlOn) {
|
|
const eventType = dto.nextControlOn === null
|
|
? InspectionFindingVerificationEventType.CONTROL_DATE_CLEARED
|
|
: before.nextControlOn === null
|
|
? InspectionFindingVerificationEventType.CONTROL_DATE_DEFINED
|
|
: InspectionFindingVerificationEventType.CONTROL_DATE_CHANGED;
|
|
await appendVerificationEvent(manager, {
|
|
findingId: id,
|
|
eventType,
|
|
occurredAt: new Date(),
|
|
targetControlOn: dto.nextControlOn,
|
|
previousControlOn: before.nextControlOn,
|
|
nextControlOn: dto.nextControlOn,
|
|
notes: dto.nextControlOn === null
|
|
? 'Fecha de verificación retirada desde seguimiento de oficina'
|
|
: before.nextControlOn === null
|
|
? 'Fecha de verificación definida desde seguimiento de oficina'
|
|
: 'Fecha de verificación reprogramada desde seguimiento de oficina',
|
|
}, principal);
|
|
}
|
|
const versionNumber = await this.captureVersion(
|
|
manager,
|
|
finding,
|
|
InspectionFindingVersionEvent.FOLLOW_UP_UPDATED,
|
|
principal,
|
|
);
|
|
const updated = await this.loadView(manager, id);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_FINDING_FOLLOW_UP_UPDATED,
|
|
entityType: 'inspection_finding',
|
|
entityId: id,
|
|
beforeData: this.auditView(before),
|
|
afterData: this.auditView(updated),
|
|
metadata: {
|
|
actId: updated.document.actId,
|
|
visitId: updated.document.visitId,
|
|
versionNumber,
|
|
remainsOpen: updated.status === InspectionFindingStatus.OPEN,
|
|
},
|
|
}, manager);
|
|
return updated;
|
|
});
|
|
}
|
|
|
|
private findingSelect(where: string): string {
|
|
return `
|
|
SELECT
|
|
finding.id,
|
|
finding.act_id AS "actId",
|
|
finding.asset_id AS "assetId",
|
|
finding.catalog_item_id AS "catalogItemId",
|
|
finding.finding_number AS "findingNumber",
|
|
finding.code,
|
|
finding.status,
|
|
finding.title,
|
|
finding.description,
|
|
finding.legal_basis AS "legalBasis",
|
|
finding.glossary,
|
|
finding.catalog_revision AS "catalogRevision",
|
|
finding.suggested_severity AS "suggestedSeverity",
|
|
finding.severity,
|
|
finding.correction_due_on AS "correctionDueOn",
|
|
finding.response_due_basis AS "responseDueBasis",
|
|
finding.response_due_days AS "responseDueDays",
|
|
finding.response_due_base_on AS "responseDueBaseOn",
|
|
finding.report_notified_on AS "reportNotifiedOn",
|
|
finding.company_response AS "companyResponse",
|
|
finding.company_response_received_on AS "companyResponseReceivedOn",
|
|
finding.company_committed_correction_on AS "companyCommittedCorrectionOn",
|
|
finding.next_control_on AS "nextControlOn",
|
|
finding.current_version AS "currentVersion",
|
|
finding.closed_at AS "closedAt",
|
|
finding.closure_notes AS "closureNotes",
|
|
JSONB_BUILD_OBJECT(
|
|
'id', asset.id,
|
|
'code', asset.code,
|
|
'name', asset.name,
|
|
'commonName', asset.common_name,
|
|
'typeName', asset_type.name,
|
|
'operatorCompany', CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id', company.id, 'code', company.code, 'name', company.name
|
|
) END,
|
|
'operationalArea', CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id', area.id, 'code', area.code, 'name', area.name
|
|
) END
|
|
) AS asset,
|
|
CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id', catalog.id,
|
|
'code', catalog.code,
|
|
'categoryId', category.id,
|
|
'categoryName', category.name,
|
|
'sourceNumber', catalog.source_number,
|
|
'revision', finding.catalog_revision,
|
|
'suggestedSeverity', finding.suggested_severity
|
|
) END AS catalog,
|
|
JSONB_BUILD_OBJECT(
|
|
'actId', act.id,
|
|
'actCode', act.code,
|
|
'actStatus', act.status,
|
|
'visitId', visit.id,
|
|
'visitCode', visit.code,
|
|
'visitStatus', visit.status
|
|
) AS document,
|
|
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.planned_start_at
|
|
) END AS "verificationVisit",
|
|
CASE WHEN latest_verification.visit_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'visitId', latest_verification.visit_id,
|
|
'visitCode', latest_verification.visit_code,
|
|
'visitStatus', latest_verification.visit_status,
|
|
'targetControlOn', latest_verification.target_control_on,
|
|
'outcome', latest_verification.outcome,
|
|
'resultNotes', latest_verification.result_notes,
|
|
'verifiedAt', latest_verification.verified_at,
|
|
'resultRecordedAt', latest_verification.result_recorded_at,
|
|
'rescheduledControlOn', latest_verification.rescheduled_control_on,
|
|
'evidenceCount', latest_verification.evidence_count
|
|
) END AS "latestVerification",
|
|
finding.created_at AS "createdAt",
|
|
finding.updated_at AS "updatedAt"
|
|
FROM inspection_findings finding
|
|
INNER JOIN inspection_acts act ON act.id = finding.act_id
|
|
INNER JOIN inspection_visits visit ON 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
|
|
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
|
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
|
LEFT JOIN LATERAL (
|
|
SELECT verification_target.id, verification_target.code,
|
|
verification_target.status, verification_target.planned_start_at
|
|
FROM inspection_finding_verification_visits verification_link
|
|
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
|
|
WHERE verification_link.finding_id = finding.id
|
|
AND verification_target.status IN ('DRAFT', 'PLANNED', 'IN_PROGRESS')
|
|
ORDER BY verification_link.created_at DESC, verification_link.id DESC
|
|
LIMIT 1
|
|
) verification_visit ON true
|
|
LEFT JOIN LATERAL (
|
|
SELECT
|
|
verification_link.visit_id,
|
|
verification_target.code AS visit_code,
|
|
verification_target.status AS visit_status,
|
|
verification_link.target_control_on,
|
|
verification_link.outcome,
|
|
verification_link.result_notes,
|
|
verification_link.verified_at,
|
|
verification_link.result_recorded_at,
|
|
verification_link.rescheduled_control_on,
|
|
(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 evidence_count
|
|
FROM inspection_finding_verification_visits verification_link
|
|
INNER JOIN inspection_visits verification_target ON verification_target.id = verification_link.visit_id
|
|
WHERE verification_link.finding_id = finding.id
|
|
ORDER BY COALESCE(verification_link.result_recorded_at, verification_link.created_at) DESC,
|
|
verification_link.id DESC
|
|
LIMIT 1
|
|
) latest_verification ON true
|
|
${where}
|
|
`;
|
|
}
|
|
|
|
private async loadView(manager: EntityManager, id: string): Promise<InspectionFindingView> {
|
|
const [finding] = await manager.query(
|
|
this.findingSelect('WHERE finding.id = $1'),
|
|
[id],
|
|
) as InspectionFindingListItem[];
|
|
if (!finding) throw findingNotFound();
|
|
const versions = await manager.query(`
|
|
SELECT
|
|
id,
|
|
version_number AS "versionNumber",
|
|
event,
|
|
snapshot,
|
|
actor_user_id AS "actorUserId",
|
|
actor_username AS "actorUsername",
|
|
created_at AS "createdAt"
|
|
FROM inspection_finding_versions
|
|
WHERE finding_id = $1
|
|
ORDER BY version_number DESC
|
|
`, [id]) as FindingVersionView[];
|
|
const verificationHistory = await this.loadVerificationHistory(manager, id);
|
|
return { ...finding, versions, verificationHistory, officeWorkflow: this.officeWorkflow(finding) };
|
|
}
|
|
|
|
private async loadVerificationHistory(
|
|
manager: EntityManager,
|
|
findingId: string,
|
|
): Promise<FindingVerificationHistoryEvent[]> {
|
|
return manager.query(`
|
|
SELECT
|
|
event.id,
|
|
event.event_type AS "eventType",
|
|
event.occurred_at AS "occurredAt",
|
|
event.target_control_on AS "targetControlOn",
|
|
event.previous_control_on AS "previousControlOn",
|
|
event.next_control_on AS "nextControlOn",
|
|
event.outcome,
|
|
event.notes,
|
|
event.actor_user_id AS "actorUserId",
|
|
COALESCE(event.actor_username, actor.username) AS "actorUsername",
|
|
CASE WHEN visit.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id', visit.id,
|
|
'code', visit.code,
|
|
'status', visit.status,
|
|
'plannedStartAt', visit.planned_start_at
|
|
) END AS "verificationVisit",
|
|
CASE WHEN visit.id IS NULL THEN 0 ELSE (
|
|
SELECT COUNT(*)::integer
|
|
FROM inspection_finding_evidence evidence
|
|
WHERE evidence.finding_id = event.finding_id
|
|
AND evidence.verification_visit_id = visit.id
|
|
AND evidence.purpose = 'VERIFICATION'
|
|
) END AS "evidenceCount"
|
|
FROM inspection_finding_verification_events event
|
|
LEFT JOIN inspection_visits visit ON visit.id = event.verification_visit_id
|
|
LEFT JOIN users actor ON actor.id = event.actor_user_id
|
|
WHERE event.finding_id = $1
|
|
ORDER BY event.occurred_at DESC, event.created_at DESC, event.id DESC
|
|
`, [findingId]) as Promise<FindingVerificationHistoryEvent[]>;
|
|
}
|
|
|
|
private officeWorkflow(finding: InspectionFindingListItem): InspectionFindingOfficeWorkflow {
|
|
if (finding.status === InspectionFindingStatus.CLOSED) {
|
|
return {
|
|
stage: 'CLOSED',
|
|
label: 'Hallazgo cerrado',
|
|
action: 'El seguimiento está finalizado y permanece disponible en el expediente histórico.',
|
|
dueOn: null,
|
|
overdue: false,
|
|
};
|
|
}
|
|
|
|
const today = this.mendozaDate();
|
|
const latest = finding.latestVerification;
|
|
if (latest?.outcome === 'RESOLVED' && latest.visitStatus === InspectionVisitStatus.CLOSED) {
|
|
return {
|
|
stage: 'READY_TO_CLOSE',
|
|
label: 'Listo para cierre',
|
|
action: 'Revisar la evidencia de verificación y registrar la conclusión administrativa.',
|
|
dueOn: null,
|
|
overdue: false,
|
|
};
|
|
}
|
|
|
|
if (latest?.outcome === 'NOT_RESOLVED' && !finding.nextControlOn) {
|
|
return {
|
|
stage: 'RESCHEDULE_VERIFICATION',
|
|
label: 'Requiere nueva verificación',
|
|
action: 'Definir una nueva fecha de verificación para devolver el hallazgo a planificación.',
|
|
dueOn: null,
|
|
overdue: false,
|
|
};
|
|
}
|
|
|
|
if (!finding.companyResponseReceivedOn) {
|
|
const overdue = Boolean(finding.correctionDueOn && finding.correctionDueOn < today);
|
|
return {
|
|
stage: overdue ? 'COMPANY_OVERDUE' : 'WAITING_COMPANY',
|
|
label: overdue ? 'Respuesta de empresa vencida' : 'Esperando respuesta de empresa',
|
|
action: overdue
|
|
? 'Registrar la presentación recibida o gestionar el reclamo administrativo.'
|
|
: 'Registrar la respuesta de la empresa cuando sea recibida.',
|
|
dueOn: finding.correctionDueOn,
|
|
overdue,
|
|
};
|
|
}
|
|
|
|
if (!finding.nextControlOn) {
|
|
return {
|
|
stage: 'DEFINE_VERIFICATION',
|
|
label: 'Definir fecha de verificación',
|
|
action: 'Establecer cuándo debe volver a controlarse el hallazgo.',
|
|
dueOn: null,
|
|
overdue: false,
|
|
};
|
|
}
|
|
|
|
if (finding.verificationVisit) {
|
|
return {
|
|
stage: 'VERIFICATION_PLANNED',
|
|
label: 'Verificación planificada',
|
|
action: 'La visita ya está vinculada. Consultar o completar la inspección de verificación.',
|
|
dueOn: finding.nextControlOn,
|
|
overdue: finding.nextControlOn < today,
|
|
};
|
|
}
|
|
|
|
return {
|
|
stage: 'PLAN_VERIFICATION',
|
|
label: finding.nextControlOn < today ? 'Verificación vencida sin visita' : 'Verificación pendiente de planificación',
|
|
action: 'Crear una visita de verificación para la fecha operativa definida.',
|
|
dueOn: finding.nextControlOn,
|
|
overdue: finding.nextControlOn < today,
|
|
};
|
|
}
|
|
|
|
private mendozaDate(): string {
|
|
return new Intl.DateTimeFormat('en-CA', {
|
|
timeZone: 'America/Argentina/Mendoza',
|
|
year: 'numeric',
|
|
month: '2-digit',
|
|
day: '2-digit',
|
|
}).format(new Date());
|
|
}
|
|
|
|
private async requireAct(manager: EntityManager, id: string): Promise<void> {
|
|
const rows = await manager.query('SELECT 1 FROM inspection_acts WHERE id = $1', [id]) as unknown[];
|
|
if (rows.length === 0) throw actNotFound();
|
|
}
|
|
|
|
private async lockAct(manager: EntityManager, id: string): Promise<InspectionAct> {
|
|
const act = await manager.getRepository(InspectionAct)
|
|
.createQueryBuilder('act')
|
|
.where('act.id = :id', { id })
|
|
.setLock('pessimistic_write')
|
|
.getOne();
|
|
if (!act) throw actNotFound();
|
|
return act;
|
|
}
|
|
|
|
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 new NotFoundException({
|
|
code: 'INSPECTION_VISIT_NOT_FOUND',
|
|
message: 'Visita de inspección no encontrada',
|
|
});
|
|
}
|
|
return visit;
|
|
}
|
|
|
|
private async lockFinding(manager: EntityManager, id: string): Promise<InspectionFinding> {
|
|
const finding = await manager.getRepository(InspectionFinding)
|
|
.createQueryBuilder('finding')
|
|
.where('finding.id = :id', { id })
|
|
.setLock('pessimistic_write')
|
|
.getOne();
|
|
if (!finding) throw findingNotFound();
|
|
return finding;
|
|
}
|
|
|
|
private assertActEditable(act: InspectionAct, visit: InspectionVisit): void {
|
|
if (act.status !== InspectionActStatus.DRAFT || visit.status !== InspectionVisitStatus.IN_PROGRESS) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_FINDING_ACT_NOT_EDITABLE',
|
|
message: 'Los hallazgos sólo se editan mientras el acta está en borrador y la visita en curso',
|
|
});
|
|
}
|
|
}
|
|
|
|
private assertFindingOpen(finding: InspectionFinding): void {
|
|
if (finding.status !== InspectionFindingStatus.OPEN) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_FINDING_NOT_OPEN',
|
|
message: 'El hallazgo ya no está abierto',
|
|
});
|
|
}
|
|
}
|
|
|
|
private async assertActorAssigned(
|
|
manager: EntityManager,
|
|
visitId: string,
|
|
principal: AuthPrincipal,
|
|
): Promise<void> {
|
|
if (principal.permissions.includes('inspections.manage')) return;
|
|
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 ForbiddenException({
|
|
code: 'INSPECTION_VISIT_NOT_ASSIGNED',
|
|
message: 'La visita no está asignada al usuario actual',
|
|
});
|
|
}
|
|
}
|
|
|
|
private async assertActAsset(manager: EntityManager, actId: string, assetId: string): Promise<void> {
|
|
const rows = await manager.query(`
|
|
SELECT 1 FROM inspection_act_assets
|
|
WHERE act_id = $1 AND asset_id = $2 AND included = true
|
|
`, [actId, assetId]) as unknown[];
|
|
if (rows.length === 0) {
|
|
throw new BadRequestException({
|
|
code: 'INSPECTION_FINDING_ASSET_INVALID',
|
|
message: 'El activo del hallazgo debe estar incluido en el acta',
|
|
});
|
|
}
|
|
}
|
|
|
|
private async requireCatalogItem(
|
|
manager: EntityManager,
|
|
id: string,
|
|
assetId: string,
|
|
): Promise<CatalogSnapshot> {
|
|
const [item] = await manager.query(`
|
|
SELECT
|
|
item.id,
|
|
item.title,
|
|
item.legal_basis AS "legalBasis",
|
|
item.glossary,
|
|
item.revision,
|
|
item.suggested_severity AS "suggestedSeverity"
|
|
FROM finding_catalog_items item
|
|
INNER JOIN finding_categories category ON category.id = item.category_id
|
|
INNER JOIN assets asset ON asset.id = $2
|
|
LEFT JOIN finding_catalog_asset_type_profiles profile
|
|
ON profile.asset_type_id = asset.asset_type_id
|
|
LEFT JOIN finding_catalog_item_asset_types mapping
|
|
ON mapping.catalog_item_id = item.id
|
|
AND mapping.asset_type_id = asset.asset_type_id
|
|
LEFT JOIN finding_catalog_asset_overrides override
|
|
ON override.catalog_item_id = item.id
|
|
AND override.asset_id = asset.id
|
|
WHERE item.id = $1
|
|
AND item.is_active = true
|
|
AND category.is_active = true
|
|
AND COALESCE(
|
|
override.is_enabled,
|
|
CASE WHEN profile.asset_type_id IS NULL THEN true ELSE mapping.id IS NOT NULL END
|
|
) = true
|
|
`, [id, assetId]) as CatalogSnapshot[];
|
|
if (!item) {
|
|
throw new BadRequestException({
|
|
code: 'FINDING_CATALOG_ITEM_NOT_APPLICABLE',
|
|
message: 'El hallazgo seleccionado no está habilitado para este elemento del Inventario',
|
|
});
|
|
}
|
|
return item;
|
|
}
|
|
|
|
private async allocateFindingNumber(manager: EntityManager, actId: string): Promise<number> {
|
|
const [row] = await manager.query(`
|
|
SELECT COALESCE(MAX(finding_number), 0)::integer + 1 AS number
|
|
FROM inspection_findings
|
|
WHERE act_id = $1
|
|
`, [actId]) as Array<{ number: number }>;
|
|
return Number(row.number);
|
|
}
|
|
|
|
private async captureVersion(
|
|
manager: EntityManager,
|
|
finding: InspectionFinding,
|
|
event: InspectionFindingVersionEvent,
|
|
principal: AuthPrincipal,
|
|
): Promise<number> {
|
|
const [row] = await manager.query(`
|
|
UPDATE inspection_findings
|
|
SET current_version = current_version + 1
|
|
WHERE id = $1
|
|
RETURNING current_version AS "versionNumber"
|
|
`, [finding.id]) as Array<{ versionNumber: number }>;
|
|
const versionNumber = Number(row.versionNumber);
|
|
finding.currentVersion = versionNumber;
|
|
const snapshot = await this.buildSnapshot(manager, finding.id);
|
|
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)
|
|
`, [finding.id, versionNumber, event, snapshot, principal.userId, principal.username]);
|
|
return versionNumber;
|
|
}
|
|
|
|
private async buildSnapshot(
|
|
manager: EntityManager,
|
|
findingId: string,
|
|
): Promise<Record<string, unknown>> {
|
|
const [row] = 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,
|
|
'legalBasis', finding.legal_basis,
|
|
'glossary', finding.glossary,
|
|
'catalogRevision', finding.catalog_revision,
|
|
'suggestedSeverity', finding.suggested_severity,
|
|
'severity', finding.severity,
|
|
'correctionDueOn', finding.correction_due_on,
|
|
'responseDueBasis', finding.response_due_basis,
|
|
'responseDueDays', finding.response_due_days,
|
|
'responseDueBaseOn', finding.response_due_base_on,
|
|
'reportNotifiedOn', finding.report_notified_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,
|
|
'act', JSONB_BUILD_OBJECT('id', act.id, 'code', act.code, 'status', act.status),
|
|
'visit', JSONB_BUILD_OBJECT('id', visit.id, 'code', visit.code, 'status', visit.status),
|
|
'asset', JSONB_BUILD_OBJECT(
|
|
'id', asset.id, 'code', asset.code, 'name', asset.name,
|
|
'typeId', asset.asset_type_id, 'currentVersion', asset.current_version
|
|
),
|
|
'catalog', CASE WHEN catalog.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id', catalog.id, 'code', catalog.code, 'sourceNumber', catalog.source_number,
|
|
'revision', finding.catalog_revision, 'suggestedSeverity', finding.suggested_severity, 'categoryCode', category.code,
|
|
'categoryName', category.name
|
|
) END
|
|
) AS snapshot
|
|
FROM inspection_findings finding
|
|
INNER JOIN inspection_acts act ON act.id = finding.act_id
|
|
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
|
INNER JOIN assets asset ON asset.id = finding.asset_id
|
|
LEFT JOIN finding_catalog_items catalog ON catalog.id = finding.catalog_item_id
|
|
LEFT JOIN finding_categories category ON category.id = catalog.category_id
|
|
WHERE finding.id = $1
|
|
`, [findingId]) as Array<{ snapshot: Record<string, unknown> }>;
|
|
if (!row) throw findingNotFound();
|
|
return row.snapshot;
|
|
}
|
|
|
|
private auditView(finding: InspectionFindingListItem): Record<string, unknown> {
|
|
return {
|
|
actId: finding.actId,
|
|
visitId: finding.document.visitId,
|
|
assetId: finding.assetId,
|
|
catalogItemId: finding.catalogItemId,
|
|
code: finding.code,
|
|
status: finding.status,
|
|
title: finding.title,
|
|
description: finding.description,
|
|
suggestedSeverity: finding.suggestedSeverity,
|
|
severity: finding.severity,
|
|
correctionDueOn: finding.correctionDueOn,
|
|
responseDueBasis: finding.responseDueBasis,
|
|
responseDueDays: finding.responseDueDays,
|
|
responseDueBaseOn: finding.responseDueBaseOn,
|
|
reportNotifiedOn: finding.reportNotifiedOn,
|
|
companyResponse: finding.companyResponse,
|
|
companyResponseReceivedOn: finding.companyResponseReceivedOn,
|
|
companyCommittedCorrectionOn: finding.companyCommittedCorrectionOn,
|
|
nextControlOn: finding.nextControlOn,
|
|
currentVersion: finding.currentVersion,
|
|
closedAt: finding.closedAt,
|
|
closureNotes: finding.closureNotes,
|
|
};
|
|
}
|
|
}
|