F4.2 · bloquear Acta con urgencia y plazo congelado
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { InspectionActUrgency } from '../../database/entities';
|
||||
|
||||
export class LockInspectionActDto {
|
||||
@IsEnum(InspectionActUrgency)
|
||||
urgency!: InspectionActUrgency;
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
import { Body, Controller, Param, ParseUUIDPipe, Post, Req } from '@nestjs/common';
|
||||
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
|
||||
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
|
||||
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
||||
import { LockInspectionActDto } from './dto/lock-inspection-act.dto';
|
||||
import { InspectionActLifecycleService } from './inspection-act-lifecycle.service';
|
||||
|
||||
@Controller('inspection-acts/:actId')
|
||||
export class InspectionActLifecycleController {
|
||||
constructor(private readonly lifecycle: InspectionActLifecycleService) {}
|
||||
|
||||
@Post('lock')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
lock(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@Body() dto: LockInspectionActDto,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.lifecycle.lock(actId, dto, principal, request);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -48,26 +48,6 @@ export class InspectionClosingController {
|
||||
return this.closing.upsertResponsible(actId, dto, principal, request);
|
||||
}
|
||||
|
||||
@Post('ready')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
ready(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.prepare(actId, principal, request);
|
||||
}
|
||||
|
||||
@Post('reopen')
|
||||
@RequirePermissions('inspection_closure.prepare')
|
||||
reopen(
|
||||
@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string,
|
||||
@CurrentAuth() principal: AuthPrincipal,
|
||||
@Req() request: RequestWithContext,
|
||||
) {
|
||||
return this.closing.reopen(actId, principal, request);
|
||||
}
|
||||
|
||||
@Post('signatures/inspector')
|
||||
@RequirePermissions('inspection_closure.sign')
|
||||
@UseInterceptors(FileInterceptor('file', {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuditModule } from '../audit/audit.module';
|
||||
import { InspectionDeadlinesModule } from '../inspection-deadlines/inspection-deadlines.module';
|
||||
import { InspectionReportsModule } from '../inspection-reports/inspection-reports.module';
|
||||
import { InspectionActLifecycleController } from './inspection-act-lifecycle.controller';
|
||||
import { InspectionActLifecycleService } from './inspection-act-lifecycle.service';
|
||||
import {
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
@@ -8,8 +11,12 @@ import {
|
||||
import { InspectionClosingService } from './inspection-closing.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuditModule, InspectionReportsModule],
|
||||
controllers: [InspectionClosingController, InspectionSignatureContentController],
|
||||
providers: [InspectionClosingService],
|
||||
imports: [AuditModule, InspectionDeadlinesModule, InspectionReportsModule],
|
||||
controllers: [
|
||||
InspectionActLifecycleController,
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
],
|
||||
providers: [InspectionActLifecycleService, InspectionClosingService],
|
||||
})
|
||||
export class InspectionClosingModule {}
|
||||
|
||||
@@ -1,16 +1,32 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { DataSource } from 'typeorm';
|
||||
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 {
|
||||
InspectionActUrgency,
|
||||
InspectionDeadlineBasis,
|
||||
InspectionDeadlineDayType,
|
||||
InspectionDeadlinePolicy,
|
||||
InspectionNonWorkingDay,
|
||||
} from '../database/entities';
|
||||
import type { UpdateInspectionDeadlinePolicyDto } from './dto/update-inspection-deadline-policy.dto';
|
||||
import type { UpsertInspectionNonWorkingDayDto } from './dto/upsert-inspection-non-working-day.dto';
|
||||
|
||||
export interface InspectionDeadlineSnapshot {
|
||||
urgency: InspectionActUrgency;
|
||||
days: number;
|
||||
dayType: InspectionDeadlineDayType;
|
||||
basis: InspectionDeadlineBasis;
|
||||
capturedAt: string;
|
||||
}
|
||||
|
||||
export interface InspectionDeadlineLockResult {
|
||||
snapshot: InspectionDeadlineSnapshot;
|
||||
baseOn: string | null;
|
||||
dueOn: string | null;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class InspectionDeadlinesService {
|
||||
constructor(
|
||||
@@ -68,6 +84,151 @@ export class InspectionDeadlinesService {
|
||||
});
|
||||
}
|
||||
|
||||
async snapshotForLock(
|
||||
manager: EntityManager,
|
||||
urgency: InspectionActUrgency,
|
||||
occurredAt: Date,
|
||||
): Promise<InspectionDeadlineLockResult> {
|
||||
const [policy] = (await manager.query(`
|
||||
SELECT
|
||||
urgency,
|
||||
days,
|
||||
day_type AS "dayType",
|
||||
basis
|
||||
FROM inspection_deadline_policies
|
||||
WHERE urgency = $1
|
||||
FOR SHARE
|
||||
`, [urgency])) as Array<{
|
||||
urgency: InspectionActUrgency;
|
||||
days: number;
|
||||
dayType: InspectionDeadlineDayType;
|
||||
basis: InspectionDeadlineBasis;
|
||||
}>;
|
||||
if (!policy) {
|
||||
throw new NotFoundException({
|
||||
code: 'INSPECTION_DEADLINE_POLICY_NOT_FOUND',
|
||||
message: 'No existe una política de plazo para la urgencia seleccionada',
|
||||
});
|
||||
}
|
||||
|
||||
const snapshot: InspectionDeadlineSnapshot = {
|
||||
urgency: policy.urgency,
|
||||
days: Number(policy.days),
|
||||
dayType: policy.dayType,
|
||||
basis: policy.basis,
|
||||
capturedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (policy.basis === InspectionDeadlineBasis.GEDO_LOAD_DATE) {
|
||||
return { snapshot, baseOn: null, dueOn: null };
|
||||
}
|
||||
|
||||
const [base] = (await manager.query(`
|
||||
SELECT TO_CHAR(
|
||||
$1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza',
|
||||
'YYYY-MM-DD'
|
||||
) AS day
|
||||
`, [occurredAt])) as Array<{ day: string }>;
|
||||
if (!base?.day) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_ACT_DATE_INVALID',
|
||||
message: 'No se pudo determinar la fecha del Acta para calcular el plazo',
|
||||
});
|
||||
}
|
||||
const dueOn = await this.calculateDueOn(
|
||||
manager,
|
||||
base.day,
|
||||
snapshot.days,
|
||||
snapshot.dayType,
|
||||
);
|
||||
return { snapshot, baseOn: base.day, dueOn };
|
||||
}
|
||||
|
||||
async calculateDueOn(
|
||||
manager: EntityManager,
|
||||
baseOn: string,
|
||||
days: number,
|
||||
dayType: InspectionDeadlineDayType,
|
||||
): Promise<string> {
|
||||
if (!Number.isInteger(days) || days < 1 || days > 365) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_DEADLINE_DAYS_INVALID',
|
||||
message: 'La cantidad de días del plazo no es válida',
|
||||
});
|
||||
}
|
||||
|
||||
if (dayType === InspectionDeadlineDayType.CALENDAR) {
|
||||
const [row] = (await manager.query(`
|
||||
SELECT ($1::date + $2::integer)::text AS "dueOn"
|
||||
`, [baseOn, days])) as Array<{ dueOn: string }>;
|
||||
return row.dueOn;
|
||||
}
|
||||
|
||||
const [row] = (await manager.query(`
|
||||
SELECT candidate.day::text AS "dueOn"
|
||||
FROM (
|
||||
SELECT generated::date AS day
|
||||
FROM generate_series(
|
||||
$1::date + 1,
|
||||
$1::date + (($2::integer * 3) + 31),
|
||||
interval '1 day'
|
||||
) generated
|
||||
WHERE EXTRACT(ISODOW FROM generated) BETWEEN 1 AND 5
|
||||
AND NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM inspection_non_working_days holiday
|
||||
WHERE holiday.day = generated::date
|
||||
AND holiday.enabled = true
|
||||
)
|
||||
ORDER BY generated
|
||||
LIMIT 1 OFFSET ($2::integer - 1)
|
||||
) candidate
|
||||
`, [baseOn, days])) as Array<{ dueOn: string }>;
|
||||
if (!row?.dueOn) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_DEADLINE_CALCULATION_FAILED',
|
||||
message: 'No se pudo calcular el vencimiento con el calendario configurado',
|
||||
});
|
||||
}
|
||||
return row.dueOn;
|
||||
}
|
||||
|
||||
async applyGedoOfficialization(
|
||||
manager: EntityManager,
|
||||
actId: string,
|
||||
officializedOn: string,
|
||||
): Promise<string | null> {
|
||||
const [act] = (await manager.query(`
|
||||
SELECT
|
||||
deadline_days AS "days",
|
||||
deadline_day_type AS "dayType",
|
||||
deadline_basis AS "basis"
|
||||
FROM inspection_acts
|
||||
WHERE id = $1
|
||||
FOR UPDATE
|
||||
`, [actId])) as Array<{
|
||||
days: number | null;
|
||||
dayType: InspectionDeadlineDayType | null;
|
||||
basis: InspectionDeadlineBasis | null;
|
||||
}>;
|
||||
if (!act || act.basis !== InspectionDeadlineBasis.GEDO_LOAD_DATE) return null;
|
||||
if (!act.days || !act.dayType) {
|
||||
throw new BadRequestException({
|
||||
code: 'INSPECTION_ACT_DEADLINE_SNAPSHOT_MISSING',
|
||||
message: 'El Acta no conserva la política de plazo requerida',
|
||||
});
|
||||
}
|
||||
const dueOn = await this.calculateDueOn(manager, officializedOn, Number(act.days), act.dayType);
|
||||
await manager.query(`
|
||||
UPDATE inspection_acts
|
||||
SET deadline_base_on = $2::date,
|
||||
deadline_due_on = $3::date,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = $1
|
||||
`, [actId, officializedOn, dueOn]);
|
||||
return dueOn;
|
||||
}
|
||||
|
||||
async listNonWorkingDays(year?: string) {
|
||||
const parsedYear = year === undefined ? null : Number(year);
|
||||
if (parsedYear !== null && (!Number.isInteger(parsedYear) || parsedYear < 2000 || parsedYear > 2200)) {
|
||||
|
||||
@@ -2,6 +2,7 @@ import 'reflect-metadata';
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { REQUIRED_PERMISSIONS_KEY } from '../../src/authorization/decorators/require-permissions.decorator';
|
||||
import { InspectionActLifecycleController } from '../../src/inspection-closing/inspection-act-lifecycle.controller';
|
||||
import {
|
||||
InspectionClosingController,
|
||||
InspectionSignatureContentController,
|
||||
@@ -12,11 +13,12 @@ function permissionFor(controller: object, method: string): string[] {
|
||||
return Reflect.getMetadata(REQUIRED_PERMISSIONS_KEY, handler) as string[];
|
||||
}
|
||||
|
||||
test('D5 closing endpoints separate read, prepare, sign and close permissions', () => {
|
||||
test('F4 closing endpoints separate responsible capture, immutable lock, signing and sealing', () => {
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'get'), ['inspection_closure.read']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'responsible'), ['inspection_closure.prepare']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'ready'), ['inspection_closure.prepare']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'reopen'), ['inspection_closure.prepare']);
|
||||
assert.deepEqual(permissionFor(InspectionActLifecycleController.prototype, 'lock'), ['inspection_closure.prepare']);
|
||||
assert.equal('ready' in InspectionClosingController.prototype, false);
|
||||
assert.equal('reopen' in InspectionClosingController.prototype, false);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'inspectorSignature'), ['inspection_closure.sign']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companySignature'), ['inspection_closure.sign']);
|
||||
assert.deepEqual(permissionFor(InspectionClosingController.prototype, 'companyOutcome'), ['inspection_closure.sign']);
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
const root = process.cwd();
|
||||
const read = (relative: string) => fs.readFileSync(path.join(root, relative), 'utf8');
|
||||
|
||||
test('F4 locks an Act without requiring an artificial Finding', () => {
|
||||
const service = read('src/inspection-closing/inspection-act-lifecycle.service.ts');
|
||||
assert.doesNotMatch(service, /INSPECTION_ACT_FINDING_REQUIRED/);
|
||||
assert.doesNotMatch(service, /COUNT\(\*\).*inspection_findings[\s\S]*< 1/);
|
||||
assert.match(service, /requireVerificationResults/);
|
||||
});
|
||||
|
||||
test('F4 freezes urgency and the exact deadline policy at lock time', () => {
|
||||
const service = read('src/inspection-closing/inspection-act-lifecycle.service.ts');
|
||||
assert.match(service, /snapshotForLock/);
|
||||
assert.match(service, /deadline_policy_snapshot/);
|
||||
assert.match(service, /locked_sha256/);
|
||||
assert.match(service, /LOCKED_PENDING_SIGNATURE/);
|
||||
});
|
||||
|
||||
test('F4 removes the reopen route once an Act has been blocked', () => {
|
||||
const controller = read('src/inspection-closing/inspection-closing.controller.ts');
|
||||
assert.doesNotMatch(controller, /@Post\('reopen'\)/);
|
||||
assert.doesNotMatch(controller, /@Post\('ready'\)/);
|
||||
const lifecycle = read('src/inspection-closing/inspection-act-lifecycle.controller.ts');
|
||||
assert.match(lifecycle, /@Post\('lock'\)/);
|
||||
});
|
||||
Reference in New Issue
Block a user