F4.2 · bloquear Acta con urgencia y plazo congelado

This commit is contained in:
2026-09-07 19:51:24 -03:00
parent 0f9aa589b5
commit 367c7df45a
8 changed files with 745 additions and 27 deletions
@@ -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'\)/);
});