F4.2 · bloquear Acta con urgencia y plazo congelado
This commit is contained in:
@@ -0,0 +1,509 @@
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user