510 lines
19 KiB
TypeScript
510 lines
19 KiB
TypeScript
import {
|
|
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,
|
|
InspectionActStatus,
|
|
InspectionActVersionEvent,
|
|
InspectionVisitStatus,
|
|
} from '../database/entities';
|
|
import { InspectionDeadlinesService } from '../inspection-deadlines/inspection-deadlines.service';
|
|
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
|
import { sha256CanonicalJson } from './canonical-json';
|
|
import type { LockInspectionActDto } from './dto/lock-inspection-act.dto';
|
|
|
|
const CLOSURE_SCHEMA_VERSION = 'DH-ACT-CLOSURE-V2';
|
|
|
|
interface LockContext {
|
|
id: string;
|
|
code: string;
|
|
visitId: string;
|
|
status: InspectionActStatus;
|
|
occurredAt: Date;
|
|
currentVersion: number;
|
|
visitStatus: InspectionVisitStatus;
|
|
leadInspectorUserId: string;
|
|
}
|
|
|
|
@Injectable()
|
|
export class InspectionActLifecycleService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly audit: AuditService,
|
|
private readonly deadlines: InspectionDeadlinesService,
|
|
) {}
|
|
|
|
async lock(
|
|
actId: string,
|
|
dto: LockInspectionActDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
) {
|
|
assertMobileInspector(principal);
|
|
return this.dataSource.transaction(async (manager) => {
|
|
const context = await this.lockContext(manager, actId);
|
|
if (context.status !== InspectionActStatus.DRAFT) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_NOT_DRAFT',
|
|
message: 'Sólo puede finalizarse y bloquearse un Acta que esté en borrador',
|
|
});
|
|
}
|
|
if (context.visitStatus !== InspectionVisitStatus.IN_PROGRESS) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_VISIT_NOT_IN_PROGRESS',
|
|
message: 'El Acta sólo puede bloquearse durante una inspección en curso',
|
|
});
|
|
}
|
|
await this.assertActorAssigned(manager, context, principal);
|
|
await this.requireResponsible(manager, actId);
|
|
await this.requireVerificationResults(manager, context.visitId);
|
|
|
|
const [signatureCount] = (await manager.query(`
|
|
SELECT COUNT(*)::integer AS total
|
|
FROM inspection_act_signatures
|
|
WHERE act_id = $1
|
|
`, [actId])) as Array<{ total: number }>;
|
|
if (Number(signatureCount?.total ?? 0) > 0) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_ALREADY_SIGNED',
|
|
message: 'El Acta ya tiene una manifestación de firma y no puede volver a bloquearse',
|
|
});
|
|
}
|
|
|
|
const deadline = await this.deadlines.snapshotForLock(
|
|
manager,
|
|
dto.urgency,
|
|
new Date(context.occurredAt),
|
|
);
|
|
const lockedAt = new Date();
|
|
const [updated] = (await manager.query(`
|
|
UPDATE inspection_acts
|
|
SET status = 'READY',
|
|
urgency = $2,
|
|
deadline_days = $3,
|
|
deadline_day_type = $4,
|
|
deadline_basis = $5,
|
|
deadline_base_on = $6::date,
|
|
deadline_due_on = $7::date,
|
|
deadline_policy_snapshot = $8::jsonb,
|
|
locked_at = $9,
|
|
locked_by = $10,
|
|
current_version = current_version + 1,
|
|
updated_by = $10,
|
|
updated_at = CURRENT_TIMESTAMP
|
|
WHERE id = $1
|
|
RETURNING current_version AS "versionNumber"
|
|
`, [
|
|
actId,
|
|
dto.urgency,
|
|
deadline.snapshot.days,
|
|
deadline.snapshot.dayType,
|
|
deadline.snapshot.basis,
|
|
deadline.baseOn,
|
|
deadline.dueOn,
|
|
deadline.snapshot,
|
|
lockedAt,
|
|
principal.userId,
|
|
])) as Array<{ versionNumber: number }>;
|
|
|
|
const preparedSnapshot = await this.buildPreparedSnapshot(manager, actId, lockedAt);
|
|
const preparedSha256 = sha256CanonicalJson(preparedSnapshot);
|
|
await manager.query(`
|
|
UPDATE inspection_acts
|
|
SET locked_sha256 = $2
|
|
WHERE id = $1
|
|
`, [actId, preparedSha256]);
|
|
await manager.query(`
|
|
INSERT INTO inspection_act_closures (
|
|
act_id, schema_version, prepared_snapshot, prepared_sha256,
|
|
prepared_at, prepared_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
ON CONFLICT (act_id) DO UPDATE SET
|
|
schema_version = EXCLUDED.schema_version,
|
|
prepared_snapshot = EXCLUDED.prepared_snapshot,
|
|
prepared_sha256 = EXCLUDED.prepared_sha256,
|
|
prepared_at = EXCLUDED.prepared_at,
|
|
prepared_by = EXCLUDED.prepared_by
|
|
`, [
|
|
actId,
|
|
CLOSURE_SCHEMA_VERSION,
|
|
preparedSnapshot,
|
|
preparedSha256,
|
|
lockedAt,
|
|
principal.userId,
|
|
]);
|
|
await manager.query(`
|
|
INSERT INTO inspection_act_versions (
|
|
act_id, version_number, event, snapshot, actor_user_id, actor_username
|
|
) VALUES ($1, $2, $3, $4, $5, $6)
|
|
`, [
|
|
actId,
|
|
Number(updated.versionNumber),
|
|
InspectionActVersionEvent.READY,
|
|
preparedSnapshot,
|
|
principal.userId,
|
|
principal.username,
|
|
]);
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.INSPECTION_ACT_READY,
|
|
entityType: 'inspection_act',
|
|
entityId: actId,
|
|
afterData: {
|
|
status: 'LOCKED_PENDING_SIGNATURE',
|
|
physicalStatus: InspectionActStatus.READY,
|
|
urgency: dto.urgency,
|
|
deadlineDays: deadline.snapshot.days,
|
|
deadlineDayType: deadline.snapshot.dayType,
|
|
deadlineBasis: deadline.snapshot.basis,
|
|
deadlineBaseOn: deadline.baseOn,
|
|
deadlineDueOn: deadline.dueOn,
|
|
lockedAt: lockedAt.toISOString(),
|
|
preparedSha256,
|
|
},
|
|
metadata: {
|
|
actId,
|
|
visitId: context.visitId,
|
|
versionNumber: Number(updated.versionNumber),
|
|
immutable: true,
|
|
},
|
|
}, manager);
|
|
|
|
return {
|
|
id: actId,
|
|
code: context.code,
|
|
status: 'LOCKED_PENDING_SIGNATURE' as const,
|
|
physicalStatus: InspectionActStatus.READY,
|
|
urgency: dto.urgency,
|
|
deadline: {
|
|
days: deadline.snapshot.days,
|
|
dayType: deadline.snapshot.dayType,
|
|
basis: deadline.snapshot.basis,
|
|
baseOn: deadline.baseOn,
|
|
dueOn: deadline.dueOn,
|
|
},
|
|
lockedAt,
|
|
preparedSha256,
|
|
currentVersion: Number(updated.versionNumber),
|
|
};
|
|
});
|
|
}
|
|
|
|
private async lockContext(manager: EntityManager, actId: string): Promise<LockContext> {
|
|
const [row] = (await manager.query(`
|
|
SELECT
|
|
act.id,
|
|
act.code,
|
|
act.visit_id AS "visitId",
|
|
act.status,
|
|
act.occurred_at AS "occurredAt",
|
|
act.current_version AS "currentVersion",
|
|
visit.status AS "visitStatus",
|
|
visit.lead_inspector_user_id AS "leadInspectorUserId"
|
|
FROM inspection_acts act
|
|
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
|
WHERE act.id = $1
|
|
FOR UPDATE OF act, visit
|
|
`, [actId])) as LockContext[];
|
|
if (!row) {
|
|
throw new NotFoundException({
|
|
code: 'INSPECTION_ACT_NOT_FOUND',
|
|
message: 'Acta de inspección no encontrada',
|
|
});
|
|
}
|
|
return row;
|
|
}
|
|
|
|
private async assertActorAssigned(
|
|
manager: EntityManager,
|
|
context: LockContext,
|
|
principal: AuthPrincipal,
|
|
): Promise<void> {
|
|
if (context.leadInspectorUserId === principal.userId) return;
|
|
const [membership] = (await manager.query(`
|
|
SELECT 1 AS found
|
|
FROM inspection_visit_members
|
|
WHERE visit_id = $1
|
|
AND user_id = $2
|
|
AND included = true
|
|
LIMIT 1
|
|
`, [context.visitId, principal.userId])) as Array<{ found: number }>;
|
|
if (!membership) {
|
|
throw new ForbiddenException({
|
|
code: 'INSPECTION_ACT_ACTOR_NOT_ASSIGNED',
|
|
message: 'Sólo un inspector asignado a la inspección puede finalizar el Acta',
|
|
});
|
|
}
|
|
}
|
|
|
|
private async requireResponsible(manager: EntityManager, actId: string): Promise<void> {
|
|
const [row] = (await manager.query(`
|
|
SELECT act_id
|
|
FROM inspection_act_responsibles
|
|
WHERE act_id = $1
|
|
`, [actId])) as Array<{ act_id: string }>;
|
|
if (!row) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_ACT_RESPONSIBLE_REQUIRED',
|
|
message: 'Antes de finalizar el Acta debe identificarse al responsable de la empresa o documentar su ausencia',
|
|
});
|
|
}
|
|
}
|
|
|
|
private async requireVerificationResults(manager: EntityManager, visitId: string): Promise<void> {
|
|
const [verification] = (await manager.query(`
|
|
SELECT
|
|
COUNT(*)::integer AS total,
|
|
COUNT(*) FILTER (WHERE outcome IS NOT NULL)::integer AS completed
|
|
FROM inspection_finding_verification_visits
|
|
WHERE visit_id = $1
|
|
`, [visitId])) as Array<{ total: number; completed: number }>;
|
|
const total = Number(verification?.total ?? 0);
|
|
const completed = Number(verification?.completed ?? 0);
|
|
if (total > 0 && completed !== total) {
|
|
throw new ConflictException({
|
|
code: 'INSPECTION_VERIFICATION_RESULTS_REQUIRED',
|
|
message: 'Registrá el resultado de todas las verificaciones antes de finalizar el Acta',
|
|
});
|
|
}
|
|
}
|
|
|
|
private async buildPreparedSnapshot(
|
|
manager: EntityManager,
|
|
actId: string,
|
|
preparedAt: Date,
|
|
): Promise<Record<string, unknown>> {
|
|
const [act] = (await manager.query(`
|
|
SELECT
|
|
act.id,
|
|
act.code,
|
|
act.act_year AS "actYear",
|
|
act.act_number AS "actNumber",
|
|
act.status,
|
|
act.occurred_at AS "occurredAt",
|
|
act.title,
|
|
act.summary,
|
|
act.observations,
|
|
act.urgency,
|
|
act.deadline_days AS "deadlineDays",
|
|
act.deadline_day_type AS "deadlineDayType",
|
|
act.deadline_basis AS "deadlineBasis",
|
|
act.deadline_base_on AS "deadlineBaseOn",
|
|
act.deadline_due_on AS "deadlineDueOn",
|
|
act.deadline_policy_snapshot AS "deadlinePolicySnapshot",
|
|
act.locked_at AS "lockedAt",
|
|
act.current_version AS "currentVersion",
|
|
act.created_at AS "createdAt",
|
|
act.updated_at AS "updatedAt",
|
|
JSONB_BUILD_OBJECT(
|
|
'id', visit.id,
|
|
'code', visit.code,
|
|
'title', visit.title,
|
|
'objective', visit.objective,
|
|
'status', visit.status,
|
|
'scopeAssetId', visit.scope_asset_id,
|
|
'leadInspectorUserId', visit.lead_inspector_user_id,
|
|
'plannedStartAt', visit.planned_start_at,
|
|
'plannedEndAt', visit.planned_end_at,
|
|
'actualStartedAt', visit.actual_started_at,
|
|
'instructions', visit.instructions
|
|
) AS visit
|
|
FROM inspection_acts act
|
|
INNER JOIN inspection_visits visit ON visit.id = act.visit_id
|
|
WHERE act.id = $1
|
|
`, [actId])) as Array<Record<string, unknown>>;
|
|
if (!act) {
|
|
throw new NotFoundException({
|
|
code: 'INSPECTION_ACT_NOT_FOUND',
|
|
message: 'Acta de inspección no encontrada',
|
|
});
|
|
}
|
|
const [responsible] = (await manager.query(`
|
|
SELECT
|
|
act_id AS "actId",
|
|
attendance_status AS "attendanceStatus",
|
|
full_name AS "fullName",
|
|
document_type AS "documentType",
|
|
document_number AS "documentNumber",
|
|
position,
|
|
email,
|
|
phone,
|
|
absence_reason AS "absenceReason",
|
|
updated_by AS "updatedBy",
|
|
created_at AS "createdAt",
|
|
updated_at AS "updatedAt"
|
|
FROM inspection_act_responsibles
|
|
WHERE act_id = $1
|
|
`, [actId])) as Array<Record<string, unknown>>;
|
|
const team = await manager.query(`
|
|
SELECT
|
|
member.user_id AS "userId",
|
|
user_account.username,
|
|
user_account.first_name AS "firstName",
|
|
user_account.last_name AS "lastName",
|
|
user_account.email,
|
|
(visit.lead_inspector_user_id = member.user_id) AS "isLead"
|
|
FROM inspection_visit_members member
|
|
INNER JOIN inspection_visits visit ON visit.id = member.visit_id
|
|
INNER JOIN users user_account ON user_account.id = member.user_id
|
|
WHERE member.visit_id = $1 AND member.included = true
|
|
ORDER BY "isLead" DESC, user_account.username, member.user_id
|
|
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
|
|
const assets = await manager.query(`
|
|
SELECT
|
|
asset.id,
|
|
asset.code,
|
|
asset.name,
|
|
asset.common_name AS "commonName",
|
|
asset.description,
|
|
asset.parent_id AS "parentId",
|
|
CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id', company.id,
|
|
'code', company.code,
|
|
'name', company.name,
|
|
'commonName', company.common_name
|
|
) END AS "operatorCompany",
|
|
CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id', area.id,
|
|
'code', area.code,
|
|
'name', area.name,
|
|
'commonName', area.common_name
|
|
) END AS "operationalArea",
|
|
asset.information_status AS "informationStatus",
|
|
asset.current_version AS "currentVersion",
|
|
asset.data_origin AS "dataOrigin",
|
|
asset.source_name AS "sourceName",
|
|
asset.source_reference AS "sourceReference",
|
|
asset.source_observed_at AS "sourceObservedAt",
|
|
asset_type.id AS "typeId",
|
|
asset_type.code AS "typeCode",
|
|
asset_type.name AS "typeName",
|
|
COALESCE((
|
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
|
'definitionId', definition.id,
|
|
'code', definition.code,
|
|
'name', definition.name,
|
|
'dataType', definition.data_type,
|
|
'value', attribute_value.value
|
|
) ORDER BY definition.sort_order, definition.code, definition.id)
|
|
FROM asset_attribute_values attribute_value
|
|
INNER JOIN asset_attribute_definitions definition
|
|
ON definition.id = attribute_value.definition_id
|
|
WHERE attribute_value.asset_id = asset.id
|
|
), '[]'::jsonb) AS attributes,
|
|
CASE WHEN geometry.asset_id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'type', geometry.geometry_type,
|
|
'geojson', ST_AsGeoJSON(geometry.geometry)::jsonb,
|
|
'source', geometry.source,
|
|
'accuracyM', geometry.accuracy_m,
|
|
'capturedAt', geometry.captured_at,
|
|
'deviceLabel', geometry.device_label
|
|
) END AS geometry
|
|
FROM inspection_act_assets link
|
|
INNER JOIN assets asset ON asset.id = link.asset_id
|
|
INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id
|
|
LEFT JOIN assets company ON company.id = asset.operator_company_id
|
|
LEFT JOIN assets area ON area.id = asset.operational_area_id
|
|
LEFT JOIN asset_geometries geometry ON geometry.asset_id = asset.id
|
|
WHERE link.act_id = $1 AND link.included = true
|
|
ORDER BY asset.code, asset.id
|
|
`, [actId]) as Array<Record<string, unknown>>;
|
|
const findings = await manager.query(`
|
|
SELECT
|
|
finding.id,
|
|
finding.finding_number AS "findingNumber",
|
|
finding.code,
|
|
finding.status,
|
|
finding.asset_id AS "assetId",
|
|
finding.catalog_item_id AS "catalogItemId",
|
|
finding.title,
|
|
finding.description,
|
|
finding.legal_basis AS "legalBasis",
|
|
finding.glossary,
|
|
finding.catalog_revision AS "catalogRevision",
|
|
finding.suggested_severity AS "suggestedSeverity",
|
|
finding.severity,
|
|
finding.is_recurrence AS "isRecurrence",
|
|
finding.antecedent_finding_id AS "antecedentFindingId",
|
|
finding.correction_due_on AS "correctionDueOn",
|
|
finding.next_control_on AS "nextControlOn",
|
|
finding.current_version AS "currentVersion",
|
|
JSONB_BUILD_OBJECT(
|
|
'code', catalog.code,
|
|
'sourceNumber', catalog.source_number,
|
|
'title', catalog.title,
|
|
'revision', catalog.revision,
|
|
'suggestedSeverity', finding.suggested_severity,
|
|
'categoryCode', category.code,
|
|
'categoryName', category.name
|
|
) AS catalog,
|
|
COALESCE((
|
|
SELECT JSONB_AGG(JSONB_BUILD_OBJECT(
|
|
'id', evidence.id,
|
|
'communicationId', evidence.communication_id,
|
|
'kind', evidence.kind,
|
|
'purpose', evidence.purpose,
|
|
'originalName', evidence.original_name,
|
|
'mimeType', evidence.mime_type,
|
|
'sizeBytes', evidence.size_bytes,
|
|
'sha256', evidence.sha256,
|
|
'title', evidence.title,
|
|
'description', evidence.description,
|
|
'capturedAt', evidence.captured_at,
|
|
'latitude', evidence.latitude,
|
|
'longitude', evidence.longitude,
|
|
'accuracyM', evidence.accuracy_m,
|
|
'deviceLabel', evidence.device_label,
|
|
'source', evidence.source,
|
|
'uploadedBy', evidence.uploaded_by,
|
|
'createdAt', evidence.created_at
|
|
) ORDER BY evidence.created_at, evidence.id)
|
|
FROM inspection_finding_evidence evidence
|
|
WHERE evidence.finding_id = finding.id
|
|
), '[]'::jsonb) AS evidence
|
|
FROM inspection_findings finding
|
|
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.act_id = $1 AND finding.status <> 'VOIDED'
|
|
ORDER BY finding.finding_number, finding.id
|
|
`, [actId]) as Array<Record<string, unknown>>;
|
|
const verificationResults = await manager.query(`
|
|
SELECT
|
|
verification_link.finding_id AS "findingId",
|
|
finding.code AS "findingCode",
|
|
finding.title AS "findingTitle",
|
|
finding.description AS "findingDescription",
|
|
finding.asset_id AS "assetId",
|
|
asset.code AS "assetCode",
|
|
asset.name AS "assetName",
|
|
verification_link.target_control_on AS "targetControlOn",
|
|
verification_link.outcome,
|
|
verification_link.result_notes AS "resultNotes",
|
|
verification_link.verified_at AS "verifiedAt",
|
|
verification_link.result_recorded_at AS "resultRecordedAt",
|
|
verification_link.rescheduled_control_on AS "rescheduledControlOn"
|
|
FROM inspection_finding_verification_visits verification_link
|
|
INNER JOIN inspection_findings finding ON finding.id = verification_link.finding_id
|
|
INNER JOIN assets asset ON asset.id = finding.asset_id
|
|
WHERE verification_link.visit_id = $1
|
|
ORDER BY finding.code
|
|
`, [(act.visit as { id: string }).id]) as Array<Record<string, unknown>>;
|
|
return {
|
|
schemaVersion: CLOSURE_SCHEMA_VERSION,
|
|
preparedAt: preparedAt.toISOString(),
|
|
act,
|
|
responsible: responsible ?? null,
|
|
team,
|
|
assets,
|
|
findings,
|
|
verificationResults,
|
|
};
|
|
}
|
|
}
|