diff --git a/api-v3/src/inspection-closing/dto/lock-inspection-act.dto.ts b/api-v3/src/inspection-closing/dto/lock-inspection-act.dto.ts new file mode 100644 index 0000000..db55af8 --- /dev/null +++ b/api-v3/src/inspection-closing/dto/lock-inspection-act.dto.ts @@ -0,0 +1,7 @@ +import { IsEnum } from 'class-validator'; +import { InspectionActUrgency } from '../../database/entities'; + +export class LockInspectionActDto { + @IsEnum(InspectionActUrgency) + urgency!: InspectionActUrgency; +} diff --git a/api-v3/src/inspection-closing/inspection-act-lifecycle.controller.ts b/api-v3/src/inspection-closing/inspection-act-lifecycle.controller.ts new file mode 100644 index 0000000..cb4588b --- /dev/null +++ b/api-v3/src/inspection-closing/inspection-act-lifecycle.controller.ts @@ -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); + } +} diff --git a/api-v3/src/inspection-closing/inspection-act-lifecycle.service.ts b/api-v3/src/inspection-closing/inspection-act-lifecycle.service.ts new file mode 100644 index 0000000..cfbe5d3 --- /dev/null +++ b/api-v3/src/inspection-closing/inspection-act-lifecycle.service.ts @@ -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 { + 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 { + 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 { + 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 { + 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> { + 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>; + 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>; + 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>; + 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>; + 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>; + 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>; + return { + schemaVersion: CLOSURE_SCHEMA_VERSION, + preparedAt: preparedAt.toISOString(), + act, + responsible: responsible ?? null, + team, + assets, + findings, + verificationResults, + }; + } +} diff --git a/api-v3/src/inspection-closing/inspection-closing.controller.ts b/api-v3/src/inspection-closing/inspection-closing.controller.ts index 14e0663..538c3b7 100644 --- a/api-v3/src/inspection-closing/inspection-closing.controller.ts +++ b/api-v3/src/inspection-closing/inspection-closing.controller.ts @@ -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', { diff --git a/api-v3/src/inspection-closing/inspection-closing.module.ts b/api-v3/src/inspection-closing/inspection-closing.module.ts index 4ea68b5..1323cfc 100644 --- a/api-v3/src/inspection-closing/inspection-closing.module.ts +++ b/api-v3/src/inspection-closing/inspection-closing.module.ts @@ -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 {} diff --git a/api-v3/src/inspection-deadlines/inspection-deadlines.service.ts b/api-v3/src/inspection-deadlines/inspection-deadlines.service.ts index 945538c..9069283 100644 --- a/api-v3/src/inspection-deadlines/inspection-deadlines.service.ts +++ b/api-v3/src/inspection-deadlines/inspection-deadlines.service.ts @@ -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 { + 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 { + 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 { + 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)) { diff --git a/api-v3/test/unit/inspection-closing-authorization.test.ts b/api-v3/test/unit/inspection-closing-authorization.test.ts index 66b8037..65f22b8 100644 --- a/api-v3/test/unit/inspection-closing-authorization.test.ts +++ b/api-v3/test/unit/inspection-closing-authorization.test.ts @@ -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']); diff --git a/api-v3/test/unit/phase-f4-act-lock.test.ts b/api-v3/test/unit/phase-f4-act-lock.test.ts new file mode 100644 index 0000000..cc7bc11 --- /dev/null +++ b/api-v3/test/unit/phase-f4-act-lock.test.ts @@ -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'\)/); +});