From 4c5742c5486574c04e4d14a5f112e684d68a5be6 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Sat, 5 Sep 2026 16:47:46 -0300 Subject: [PATCH] =?UTF-8?q?F1.2=20=C2=B7=20Seguimiento=20administrativo=20?= =?UTF-8?q?por=20Acta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Plazo único por Acta, respuestas de empresa con PDF, fecha comprometida, estados administrativos, cola y calendario por Acta. --- api-v3/package-lock.json | 4 +- api-v3/package.json | 2 +- .../act-administration.controller.ts | 47 +++++ .../act-administration.module.ts | 12 ++ .../act-administration.service.ts | 180 ++++++++++++++++++ .../dto/create-act-company-response.dto.ts | 31 +++ .../dto/list-act-administration-query.dto.ts | 17 ++ .../dto/set-act-response-deadline.dto.ts | 14 ++ api-v3/src/app.module.ts | 2 + ...581600000-phase-f1-2-act-administration.ts | 39 ++++ api-v3/src/version.ts | 4 +- .../phase-f1-2-act-administration.test.ts | 42 ++++ web-v2/package-lock.json | 4 +- web-v2/package.json | 2 +- web-v2/src/app/App.tsx | 4 +- web-v2/src/config/version.ts | 4 +- web-v2/src/layout/AppLayout.tsx | 1 + web-v2/src/lib/api.ts | 33 ++++ .../src/pages/ActAdministrationDetailPage.tsx | 20 ++ web-v2/src/pages/ActAdministrationPage.tsx | 22 +++ 20 files changed, 473 insertions(+), 11 deletions(-) create mode 100644 api-v3/src/act-administration/act-administration.controller.ts create mode 100644 api-v3/src/act-administration/act-administration.module.ts create mode 100644 api-v3/src/act-administration/act-administration.service.ts create mode 100644 api-v3/src/act-administration/dto/create-act-company-response.dto.ts create mode 100644 api-v3/src/act-administration/dto/list-act-administration-query.dto.ts create mode 100644 api-v3/src/act-administration/dto/set-act-response-deadline.dto.ts create mode 100644 api-v3/src/database/migrations/1789581600000-phase-f1-2-act-administration.ts create mode 100644 api-v3/test/unit/phase-f1-2-act-administration.test.ts create mode 100644 web-v2/src/pages/ActAdministrationDetailPage.tsx create mode 100644 web-v2/src/pages/ActAdministrationPage.tsx diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index e426335..7438a4d 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.19.6-4", + "version": "0.20.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.19.6-4", + "version": "0.20.0-2", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index 8fc9ac3..a7408b5 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.20.0-1", + "version": "0.20.0-2", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/act-administration/act-administration.controller.ts b/api-v3/src/act-administration/act-administration.controller.ts new file mode 100644 index 0000000..d5cca5c --- /dev/null +++ b/api-v3/src/act-administration/act-administration.controller.ts @@ -0,0 +1,47 @@ + +import { Body, Controller, Get, Param, ParseUUIDPipe, Patch, Post, Query, Req, Res, UploadedFile, UseInterceptors } from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import type { Response } from 'express'; +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 { ActAdministrationService, MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from './act-administration.service'; +import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto'; +import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto'; +import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto'; + +@Controller('act-administration') +export class ActAdministrationQueueController { + constructor(private readonly administration: ActAdministrationService) {} + @Get('queue') @RequirePermissions('inspection_acts.read') queue(@Query() query: ListActAdministrationQueryDto) { return this.administration.queue(query); } + @Get('calendar') @RequirePermissions('inspection_acts.read') calendar(@Query('from') from?: string, @Query('to') to?: string) { return this.administration.calendar(from, to); } +} + +@Controller('inspection-acts/:actId/administration') +export class ActAdministrationController { + constructor(private readonly administration: ActAdministrationService) {} + @Get() @RequirePermissions('inspection_acts.read') get(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string) { return this.administration.detail(actId); } + @Patch('deadline') @RequirePermissions('inspection_findings.follow_up') + setDeadline(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: SetActResponseDeadlineDto, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.setDeadline(actId, dto, principal, request); } + @Post('responses') @RequirePermissions('inspection_findings.follow_up') + @UseInterceptors(FileInterceptor('file', { limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 } })) + addResponse(@Param('actId', new ParseUUIDPipe({ version: '4' })) actId: string, @Body() dto: CreateActCompanyResponseDto, @UploadedFile() file: UploadedActResponseFile | undefined, @CurrentAuth() principal: AuthPrincipal, @Req() request: RequestWithContext) { return this.administration.addResponse(actId, dto, file, principal, request); } +} + +@Controller('act-company-responses') +export class ActCompanyResponseContentController { + constructor(private readonly administration: ActAdministrationService) {} + @Get(':responseId/content') @RequirePermissions('inspection_acts.read') + async content(@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string, @Query('download') download: string | undefined, @Res() response: Response): Promise { + const item = await this.administration.responseContent(responseId); + const disposition = download === '1' ? 'attachment' : 'inline'; + const fallbackName = item.originalName.replace(/[^\x20-\x7e]/g, '_').replace(/["\\]/g, '_'); + response.setHeader('Content-Type', 'application/pdf'); + response.setHeader('Content-Length', String(item.sizeBytes)); + response.setHeader('Content-Disposition', `${disposition}; filename="${fallbackName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`); + response.setHeader('Cache-Control', 'private, no-store'); + response.setHeader('X-Content-Type-Options', 'nosniff'); + response.setHeader('Content-Security-Policy', "sandbox; default-src 'none'"); + await new Promise((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend())); + } +} diff --git a/api-v3/src/act-administration/act-administration.module.ts b/api-v3/src/act-administration/act-administration.module.ts new file mode 100644 index 0000000..7b46202 --- /dev/null +++ b/api-v3/src/act-administration/act-administration.module.ts @@ -0,0 +1,12 @@ + +import { Module } from '@nestjs/common'; +import { AuditModule } from '../audit/audit.module'; +import { ActAdministrationController, ActAdministrationQueueController, ActCompanyResponseContentController } from './act-administration.controller'; +import { ActAdministrationService } from './act-administration.service'; + +@Module({ + imports: [AuditModule], + controllers: [ActAdministrationQueueController, ActAdministrationController, ActCompanyResponseContentController], + providers: [ActAdministrationService], +}) +export class ActAdministrationModule {} diff --git a/api-v3/src/act-administration/act-administration.service.ts b/api-v3/src/act-administration/act-administration.service.ts new file mode 100644 index 0000000..c582b48 --- /dev/null +++ b/api-v3/src/act-administration/act-administration.service.ts @@ -0,0 +1,180 @@ + +import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; +import { createHash, randomUUID } from 'crypto'; +import { mkdir, stat, unlink, writeFile } from 'fs/promises'; +import { join, resolve } from 'path'; +import { DataSource } from 'typeorm'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { CreateActCompanyResponseDto } from './dto/create-act-company-response.dto'; +import { ListActAdministrationQueryDto } from './dto/list-act-administration-query.dto'; +import { SetActResponseDeadlineDto } from './dto/set-act-response-deadline.dto'; + +export const MAX_ACT_RESPONSE_BYTES = 15 * 1024 * 1024; +export interface UploadedActResponseFile { originalname: string; mimetype: string; size: number; buffer: Buffer } + +const STORAGE = resolve(process.env.ACT_COMPANY_RESPONSE_STORAGE_DIR ?? '/app/storage/act-company-responses'); + +@Injectable() +export class ActAdministrationService { + constructor(private readonly dataSource: DataSource, private readonly audit: AuditService) {} + + private baseCte() { + return ` + WITH act_rows AS ( + SELECT ia.id AS "actId", ia.code AS "actCode", ia.status AS "actStatus", ia.occurred_at AS "occurredAt", + ia.closed_at AS "closedAt", v.id AS "visitId", v.code AS "visitCode", + v.operational_area_id AS "areaId", v.operator_company_id AS "companyId", + area.name AS "areaName", COALESCE(op.legal_name, company.name) AS "companyName", + dl.id AS "deadlineEventId", dl.response_due_on AS "responseDueOn", dl.reason AS "deadlineReason", + dl.created_at AS "deadlineSetAt", rsp.id AS "latestResponseId", rsp.received_on AS "responseReceivedOn", + rsp.committed_correction_on AS "committedCorrectionOn", + COALESCE(fc.finding_count, 0)::int AS "findingCount", + COALESCE(fc.open_count, 0)::int AS "openFindingCount", + COALESCE(fc.scheduled_control_count, 0)::int AS "scheduledControlCount" + FROM inspection_acts ia + JOIN inspection_visits v ON v.id = ia.visit_id + LEFT JOIN assets area ON area.id = v.operational_area_id + LEFT JOIN assets company ON company.id = v.operator_company_id + LEFT JOIN organization_profiles op ON op.asset_id = v.operator_company_id + LEFT JOIN LATERAL ( + SELECT d.* FROM inspection_act_deadline_events d WHERE d.act_id = ia.id ORDER BY d.created_at DESC, d.id DESC LIMIT 1 + ) dl ON true + LEFT JOIN LATERAL ( + SELECT r.* FROM inspection_act_company_responses r WHERE r.act_id = ia.id ORDER BY r.received_on DESC, r.created_at DESC, r.id DESC LIMIT 1 + ) rsp ON true + LEFT JOIN LATERAL ( + SELECT COUNT(*) FILTER (WHERE f.status <> 'VOIDED') AS finding_count, + COUNT(*) FILTER (WHERE f.status = 'OPEN') AS open_count, + COUNT(*) FILTER (WHERE f.status = 'OPEN' AND f.next_control_on IS NOT NULL) AS scheduled_control_count + FROM inspection_findings f WHERE f.act_id = ia.id + ) fc ON true + WHERE ia.status IN ('CLOSED', 'RECTIFIED') + ), classified AS ( + SELECT *, CASE + WHEN "findingCount" > 0 AND "openFindingCount" = 0 THEN 'REGULARIZED' + WHEN "latestResponseId" IS NOT NULL AND "committedCorrectionOn" IS NOT NULL AND "committedCorrectionOn" < CURRENT_DATE AND "openFindingCount" > 0 THEN 'COMMITMENT_OVERDUE' + WHEN "latestResponseId" IS NOT NULL AND "openFindingCount" > 0 AND "scheduledControlCount" = 0 THEN 'VERIFICATION_PENDING' + WHEN "latestResponseId" IS NOT NULL THEN 'RESPONSE_RECEIVED' + WHEN "responseDueOn" IS NULL THEN 'NEW' + WHEN "responseDueOn" < CURRENT_DATE THEN 'OVERDUE' + WHEN "responseDueOn" <= CURRENT_DATE + 3 THEN 'DUE_SOON' + ELSE 'WAITING_RESPONSE' + END AS "adminState" + FROM act_rows + )`; + } + + async queue(query: ListActAdministrationQueryDto) { + const page = query.page ?? 1; + const pageSize = query.pageSize ?? 25; + const filters: string[] = []; + const args: unknown[] = []; + const add = (sql: string, value: unknown) => { args.push(value); filters.push(sql.replace('?', `$${args.length}`)); }; + if (query.areaId) add('"areaId" = ?', query.areaId); + if (query.companyId) add('"companyId" = ?', query.companyId); + if (query.state && query.state !== 'ALL') add('"adminState" = ?', query.state); + const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; + const rows = await this.dataSource.query( + `${this.baseCte()} SELECT *, COUNT(*) OVER()::int AS "totalRows" FROM classified ${where} ORDER BY "responseDueOn" ASC NULLS FIRST, "occurredAt" DESC LIMIT $${args.length + 1} OFFSET $${args.length + 2}`, + [...args, pageSize, (page - 1) * pageSize], + ) as Array>; + const total = Number(rows[0]?.totalRows ?? 0); + const counterFilters: string[] = []; + const counterArgs: unknown[] = []; + if (query.areaId) { counterArgs.push(query.areaId); counterFilters.push(`"areaId" = $${counterArgs.length}`); } + if (query.companyId) { counterArgs.push(query.companyId); counterFilters.push(`"companyId" = $${counterArgs.length}`); } + const countersRaw = await this.dataSource.query( + `${this.baseCte()} SELECT "adminState" AS state, COUNT(*)::int AS count FROM classified ${counterFilters.length ? `WHERE ${counterFilters.join(' AND ')}` : ''} GROUP BY "adminState"`, + counterArgs, + ) as Array<{ state: string; count: number }>; + const counters = Object.fromEntries(countersRaw.map((r) => [r.state, Number(r.count)])); + return { + data: rows.map(({ totalRows: _ignored, ...row }) => row), + counters, + meta: { page, pageSize, total, totalPages: Math.ceil(total / pageSize) }, + }; + } + + async detail(actId: string) { + const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE "actId" = $1`, [actId]); + const act = rows[0]; + if (!act) throw new NotFoundException({ code: 'ACT_ADMIN_NOT_FOUND', message: 'El Acta no está disponible para seguimiento administrativo.' }); + const deadlines = await this.dataSource.query(`SELECT id, response_due_on AS "responseDueOn", reason, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_deadline_events WHERE act_id = $1 ORDER BY created_at DESC`, [actId]); + const responses = await this.dataSource.query(`SELECT id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", mime_type AS "mimeType", size_bytes AS "sizeBytes", sha256, created_by AS "createdBy", created_at AS "createdAt" FROM inspection_act_company_responses WHERE act_id = $1 ORDER BY received_on DESC, created_at DESC`, [actId]); + const findings = await this.dataSource.query(`SELECT id, code, title, status, next_control_on AS "nextControlOn" FROM inspection_findings WHERE act_id = $1 AND status <> 'VOIDED' ORDER BY finding_number`, [actId]); + return { act, deadlines, responses, findings }; + } + + private async ensureClosedAct(actId: string) { + const rows = await this.dataSource.query(`SELECT id, code, status FROM inspection_acts WHERE id = $1`, [actId]); + const act = rows[0]; + if (!act) throw new NotFoundException({ code: 'ACT_NOT_FOUND', message: 'Acta inexistente.' }); + if (!['CLOSED', 'RECTIFIED'].includes(act.status)) throw new BadRequestException({ code: 'ACT_ADMIN_REQUIRES_CLOSED', message: 'El seguimiento administrativo comienza cuando el Acta está cerrada.' }); + return act; + } + + async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) { + const act = await this.ensureClosedAct(actId); + const rows = await this.dataSource.query( + `INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by) VALUES ($1,$2,$3,$4,$5) RETURNING id, response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`, + [randomUUID(), actId, dto.responseDueOn, dto.reason, principal.userId], + ); + await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_RESPONSE_DEADLINE_SET', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, responseDueOn: dto.responseDueOn, reason: dto.reason } }); + return rows[0]; + } + + async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) { + const act = await this.ensureClosedAct(actId); + let storedName: string | null = null; + let sha256: string | null = null; + if (file) { + if (file.size <= 0 || file.size > MAX_ACT_RESPONSE_BYTES) throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_SIZE', message: 'El PDF supera el límite permitido.' }); + if (file.mimetype !== 'application/pdf' || file.buffer.subarray(0, 5).toString('ascii') !== '%PDF-') throw new BadRequestException({ code: 'ACT_RESPONSE_FILE_TYPE', message: 'La respuesta adjunta debe ser un PDF válido.' }); + await mkdir(STORAGE, { recursive: true }); + sha256 = createHash('sha256').update(file.buffer).digest('hex'); + storedName = `${randomUUID()}.pdf`; + await writeFile(join(STORAGE, storedName), file.buffer, { flag: 'wx' }); + } + try { + const rows = await this.dataSource.query( + `INSERT INTO inspection_act_company_responses (id, act_id, received_on, details, committed_correction_on, contact_name, contact_email, original_name, stored_name, mime_type, size_bytes, sha256, created_by) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING id, received_on AS "receivedOn", details, committed_correction_on AS "committedCorrectionOn", contact_name AS "contactName", contact_email AS "contactEmail", original_name AS "originalName", size_bytes AS "sizeBytes", sha256, created_at AS "createdAt"`, + [randomUUID(), actId, dto.receivedOn, dto.details ?? null, dto.committedCorrectionOn ?? null, dto.contactName ?? null, dto.contactEmail ?? null, file?.originalname ?? null, storedName, file ? 'application/pdf' : null, file?.size ?? null, sha256, principal.userId], + ); + await this.audit.record({ actorUserId: principal.userId, actorUsername: principal.username, action: 'ACT_COMPANY_RESPONSE_RECORDED', entityType: 'inspection_act', entityId: actId, requestId: request.requestId, afterData: { actCode: act.code, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 } }); + return rows[0]; + } catch (error) { + if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined); + throw error; + } + } + + async responseContent(responseId: string) { + const rows = await this.dataSource.query(`SELECT original_name AS "originalName", stored_name AS "storedName", size_bytes AS "sizeBytes" FROM inspection_act_company_responses WHERE id = $1 AND stored_name IS NOT NULL`, [responseId]); + const row = rows[0]; + if (!row) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'PDF de respuesta inexistente.' }); + const filePath = join(STORAGE, row.storedName); + const info = await stat(filePath).catch(() => null); + if (!info?.isFile()) throw new NotFoundException({ code: 'ACT_RESPONSE_FILE_NOT_FOUND', message: 'El archivo de respuesta no está disponible.' }); + return { originalName: row.originalName as string, sizeBytes: Number(row.sizeBytes), filePath }; + } + + async calendar(from?: string, to?: string) { + const dateRe = /^\d{4}-\d{2}-\d{2}$/; + if ((from && !dateRe.test(from)) || (to && !dateRe.test(to))) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'Las fechas deben usar YYYY-MM-DD.' }); + const start = from ?? new Date().toISOString().slice(0, 10); + const endDate = new Date(`${to ?? start}T00:00:00Z`); + if (!to) endDate.setUTCDate(endDate.getUTCDate() + 60); + const end = endDate.toISOString().slice(0, 10); + if (start > end) throw new BadRequestException({ code: 'INVALID_DATE_RANGE', message: 'El rango de fechas es inválido.' }); + const rows = await this.dataSource.query(`${this.baseCte()} SELECT * FROM classified WHERE ("responseDueOn" BETWEEN $1 AND $2) OR ("committedCorrectionOn" BETWEEN $1 AND $2) ORDER BY COALESCE("responseDueOn", "committedCorrectionOn")`, [start, end]); + const events: Array> = []; + for (const row of rows) { + if (row.responseDueOn && row.responseDueOn >= start && row.responseDueOn <= end) events.push({ type: 'RESPONSE_DUE', date: row.responseDueOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState }); + if (row.committedCorrectionOn && row.committedCorrectionOn >= start && row.committedCorrectionOn <= end) events.push({ type: 'COMMITMENT_DUE', date: row.committedCorrectionOn, actId: row.actId, actCode: row.actCode, areaName: row.areaName, companyName: row.companyName, state: row.adminState }); + } + return { from: start, to: end, data: events.sort((a, b) => String(a.date).localeCompare(String(b.date))) }; + } +} diff --git a/api-v3/src/act-administration/dto/create-act-company-response.dto.ts b/api-v3/src/act-administration/dto/create-act-company-response.dto.ts new file mode 100644 index 0000000..8713942 --- /dev/null +++ b/api-v3/src/act-administration/dto/create-act-company-response.dto.ts @@ -0,0 +1,31 @@ + +import { Transform } from 'class-transformer'; +import { IsDateString, IsEmail, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class CreateActCompanyResponseDto { + @IsDateString() + receivedOn!: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(3) + @MaxLength(8000) + details?: string; + + @IsOptional() + @IsDateString() + committedCorrectionOn?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MaxLength(200) + contactName?: string; + + @IsOptional() + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsEmail() + @MaxLength(320) + contactEmail?: string; +} diff --git a/api-v3/src/act-administration/dto/list-act-administration-query.dto.ts b/api-v3/src/act-administration/dto/list-act-administration-query.dto.ts new file mode 100644 index 0000000..f7e0318 --- /dev/null +++ b/api-v3/src/act-administration/dto/list-act-administration-query.dto.ts @@ -0,0 +1,17 @@ + +import { Transform } from 'class-transformer'; +import { IsIn, IsInt, IsOptional, IsUUID, Max, Min } from 'class-validator'; + +export const ACT_ADMIN_STATES = [ + 'ALL', 'NEW', 'WAITING_RESPONSE', 'DUE_SOON', 'OVERDUE', + 'RESPONSE_RECEIVED', 'VERIFICATION_PENDING', 'COMMITMENT_OVERDUE', 'REGULARIZED', +] as const; +export type ActAdministrationState = typeof ACT_ADMIN_STATES[number]; + +export class ListActAdministrationQueryDto { + @IsOptional() @IsIn(ACT_ADMIN_STATES) state?: ActAdministrationState; + @IsOptional() @IsUUID('4') areaId?: string; + @IsOptional() @IsUUID('4') companyId?: string; + @IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) page?: number; + @IsOptional() @Transform(({ value }) => Number(value)) @IsInt() @Min(1) @Max(100) pageSize?: number; +} diff --git a/api-v3/src/act-administration/dto/set-act-response-deadline.dto.ts b/api-v3/src/act-administration/dto/set-act-response-deadline.dto.ts new file mode 100644 index 0000000..550acac --- /dev/null +++ b/api-v3/src/act-administration/dto/set-act-response-deadline.dto.ts @@ -0,0 +1,14 @@ + +import { Transform } from 'class-transformer'; +import { IsDateString, IsString, MaxLength, MinLength } from 'class-validator'; + +export class SetActResponseDeadlineDto { + @IsDateString() + responseDueOn!: string; + + @Transform(({ value }) => (typeof value === 'string' ? value.trim() : value)) + @IsString() + @MinLength(3) + @MaxLength(1000) + reason!: string; +} diff --git a/api-v3/src/app.module.ts b/api-v3/src/app.module.ts index 3a50dca..ed63e7f 100644 --- a/api-v3/src/app.module.ts +++ b/api-v3/src/app.module.ts @@ -24,6 +24,7 @@ import { InspectionClosingModule } from './inspection-closing/inspection-closing import { AssetImportsModule } from './asset-imports/asset-imports.module'; import { InspectionReportsModule } from './inspection-reports/inspection-reports.module'; import { InspectionVerificationsModule } from './inspection-verifications/inspection-verifications.module'; +import { ActAdministrationModule } from './act-administration/act-administration.module'; function required(config: ConfigService, key: string): string { const value = config.get(key); @@ -76,6 +77,7 @@ function required(config: ConfigService, key: string): string { InspectionClosingModule, InspectionReportsModule, InspectionVerificationsModule, + ActAdministrationModule, AssetImportsModule, ], controllers: [HealthController], diff --git a/api-v3/src/database/migrations/1789581600000-phase-f1-2-act-administration.ts b/api-v3/src/database/migrations/1789581600000-phase-f1-2-act-administration.ts new file mode 100644 index 0000000..6759b2f --- /dev/null +++ b/api-v3/src/database/migrations/1789581600000-phase-f1-2-act-administration.ts @@ -0,0 +1,39 @@ + +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class PhaseF12ActAdministration1789581600000 implements MigrationInterface { + name = 'PhaseF12ActAdministration1789581600000'; + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`CREATE TABLE inspection_act_deadline_events ( + id uuid PRIMARY KEY, act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE RESTRICT, + response_due_on date NOT NULL, reason text NOT NULL, created_by uuid NULL REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP + )`); + await queryRunner.query(`CREATE INDEX idx_act_deadline_events_act_created ON inspection_act_deadline_events (act_id, created_at DESC)`); + await queryRunner.query(`CREATE INDEX idx_act_deadline_events_due ON inspection_act_deadline_events (response_due_on)`); + await queryRunner.query(`CREATE TABLE inspection_act_company_responses ( + id uuid PRIMARY KEY, act_id uuid NOT NULL REFERENCES inspection_acts(id) ON DELETE RESTRICT, + received_on date NOT NULL, details text NULL, committed_correction_on date NULL, + contact_name varchar(200) NULL, contact_email varchar(320) NULL, + original_name varchar(255) NULL, stored_name varchar(100) NULL, mime_type varchar(120) NULL, + size_bytes bigint NULL, sha256 char(64) NULL, created_by uuid NULL REFERENCES users(id) ON DELETE SET NULL, + created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT chk_act_company_response_file CHECK ((stored_name IS NULL AND original_name IS NULL AND mime_type IS NULL AND size_bytes IS NULL AND sha256 IS NULL) OR (stored_name IS NOT NULL AND original_name IS NOT NULL AND mime_type = 'application/pdf' AND size_bytes > 0 AND sha256 IS NOT NULL)) + )`); + await queryRunner.query(`CREATE INDEX idx_act_company_responses_act_received ON inspection_act_company_responses (act_id, received_on DESC, created_at DESC)`); + await queryRunner.query(`CREATE INDEX idx_act_company_responses_commitment ON inspection_act_company_responses (committed_correction_on)`); + await queryRunner.query(`INSERT INTO inspection_act_deadline_events (id, act_id, response_due_on, reason, created_by, created_at) + SELECT gen_random_uuid(), act_id, MIN(correction_due_on), 'Migrado desde plazo histórico coincidente de hallazgos', NULL, CURRENT_TIMESTAMP + FROM inspection_findings WHERE correction_due_on IS NOT NULL GROUP BY act_id HAVING COUNT(DISTINCT correction_due_on) = 1`); + await queryRunner.query(`CREATE OR REPLACE FUNCTION prevent_f12_append_only_mutation() RETURNS trigger AS $$ BEGIN RAISE EXCEPTION 'F1.2 append-only: update/delete no permitido'; END; $$ LANGUAGE plpgsql`); + await queryRunner.query(`CREATE TRIGGER trg_act_deadline_events_append_only BEFORE UPDATE OR DELETE ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION prevent_f12_append_only_mutation()`); + await queryRunner.query(`CREATE TRIGGER trg_act_company_responses_append_only BEFORE UPDATE OR DELETE ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION prevent_f12_append_only_mutation()`); + } + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_company_responses_append_only ON inspection_act_company_responses`); + await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_events_append_only ON inspection_act_deadline_events`); + await queryRunner.query(`DROP FUNCTION IF EXISTS prevent_f12_append_only_mutation()`); + await queryRunner.query(`DROP TABLE inspection_act_company_responses`); + await queryRunner.query(`DROP TABLE inspection_act_deadline_events`); + } +} diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 09c6416..9dcbfe6 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.20.0-1'; -export const API_PHASE = 'F1.1'; +export const API_VERSION = '0.20.0-2'; +export const API_PHASE = 'F1.2'; diff --git a/api-v3/test/unit/phase-f1-2-act-administration.test.ts b/api-v3/test/unit/phase-f1-2-act-administration.test.ts new file mode 100644 index 0000000..9092267 --- /dev/null +++ b/api-v3/test/unit/phase-f1-2-act-administration.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; +import { validate } from 'class-validator'; +import { plainToInstance } from 'class-transformer'; +import { SetActResponseDeadlineDto } from '../../src/act-administration/dto/set-act-response-deadline.dto'; +import { CreateActCompanyResponseDto } from '../../src/act-administration/dto/create-act-company-response.dto'; + +const source = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const migration = source('src/database/migrations/1789581600000-phase-f1-2-act-administration.ts'); +const service = source('src/act-administration/act-administration.service.ts'); +const controller = source('src/act-administration/act-administration.controller.ts'); + +test('F1.2 stores one append-only administrative deadline ledger per Act', () => { + assert.match(migration, /inspection_act_deadline_events/); + assert.match(migration, /append_only/); + assert.match(service, /ACT_RESPONSE_DEADLINE_SET/); +}); + +test('F1.2 preserves ambiguous historical finding deadlines instead of collapsing them', () => { + assert.match(migration, /HAVING COUNT\(DISTINCT correction_due_on\) = 1/); +}); + +test('F1.2 stores company responses and protected PDF metadata at Act level', () => { + assert.match(migration, /inspection_act_company_responses/); + assert.match(service, /%PDF-/); + assert.match(controller, /inspection-acts\/:actId\/administration/); +}); + +test('F1.2 deadline and company response DTOs reject invalid office input', async () => { + const deadline = plainToInstance(SetActResponseDeadlineDto, { responseDueOn: 'bad', reason: 'x' }); + assert.ok((await validate(deadline)).length >= 2); + const response = plainToInstance(CreateActCompanyResponseDto, { receivedOn: 'bad', contactEmail: 'bad' }); + assert.ok((await validate(response)).length >= 2); +}); + +test('F1.2 derives administrative queues from Acts instead of replacing finding history', () => { + assert.match(service, /WAITING_RESPONSE/); + assert.match(service, /COMMITMENT_OVERDUE/); + assert.match(service, /inspection_findings/); +}); diff --git a/web-v2/package-lock.json b/web-v2/package-lock.json index dfab0fe..bf67230 100644 --- a/web-v2/package-lock.json +++ b/web-v2/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-web", - "version": "0.19.6-4", + "version": "0.20.0-2", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-web", - "version": "0.19.6-4", + "version": "0.20.0-2", "dependencies": { "maplibre-gl": "^6.0.0", "react": "^19.0.0", diff --git a/web-v2/package.json b/web-v2/package.json index 85ee2d7..d5e2351 100644 --- a/web-v2/package.json +++ b/web-v2/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-web", - "version": "0.20.0-1", + "version": "0.20.0-2", "private": true, "type": "module", "engines": { diff --git a/web-v2/src/app/App.tsx b/web-v2/src/app/App.tsx index fb4b27a..546bef8 100644 --- a/web-v2/src/app/App.tsx +++ b/web-v2/src/app/App.tsx @@ -32,6 +32,8 @@ import { ActsPage } from '../pages/ActsPage'; import { ReportsPage } from '../pages/ReportsPage'; import { ReportDetailPage } from '../pages/ReportDetailPage'; import { VerificationPlanningPage } from '../pages/VerificationPlanningPage'; +import { ActAdministrationPage } from '../pages/ActAdministrationPage'; +import { ActAdministrationDetailPage } from '../pages/ActAdministrationDetailPage'; const MapPage = lazy(() => import('../pages/MapPage').then((module) => ({ default: module.MapPage }))); @@ -71,7 +73,7 @@ export function App() { }>} />} /> }>} /> - }>} /> + }>} />} />} /> }>} />} /> }>} /> }>} /> diff --git a/web-v2/src/config/version.ts b/web-v2/src/config/version.ts index 4bc46f6..0c59f2d 100644 --- a/web-v2/src/config/version.ts +++ b/web-v2/src/config/version.ts @@ -1,2 +1,2 @@ -export const APP_VERSION = '0.20.0-1'; -export const APP_PHASE = 'Fase F1.1 · Inspección multi-Acta y firma diferida'; +export const APP_VERSION = '0.20.0-2'; +export const APP_PHASE = 'Fase F1.2 · Seguimiento administrativo por Acta'; diff --git a/web-v2/src/layout/AppLayout.tsx b/web-v2/src/layout/AppLayout.tsx index 2e4b364..5e804f5 100644 --- a/web-v2/src/layout/AppLayout.tsx +++ b/web-v2/src/layout/AppLayout.tsx @@ -19,6 +19,7 @@ const operational: NavItem[] = [ ]; const followUp: NavItem[] = [ + { to: '/seguimiento-actas', label: 'Seguimiento de actas', icon: 'clipboard', permission: 'inspection_acts.read' }, { to: '/hallazgos', label: 'Hallazgos', icon: 'alert', permission: 'inspection_findings.read' }, ]; diff --git a/web-v2/src/lib/api.ts b/web-v2/src/lib/api.ts index 9d6a8d0..d4885d2 100644 --- a/web-v2/src/lib/api.ts +++ b/web-v2/src/lib/api.ts @@ -3096,3 +3096,36 @@ export function updateDocumentDeliverySettings(input:{officeEmail?:string|null;d export function listDocumentDeliveries(){return apiRequest<{data:DocumentDeliveryItem[]}>('/document-delivery/outbox');} export function retryDocumentDelivery(id:string){return apiRequest(`/document-delivery/outbox/${id}/retry`,{method:'POST'});} export function retryPendingDocumentDeliveries(){return apiRequest<{processed:number}>('/document-delivery/outbox/retry-pending',{method:'POST'});} + +export type ActAdministrationState = 'NEW' | 'WAITING_RESPONSE' | 'DUE_SOON' | 'OVERDUE' | 'RESPONSE_RECEIVED' | 'VERIFICATION_PENDING' | 'COMMITMENT_OVERDUE' | 'REGULARIZED'; +export interface ActAdministrationQueueItem { + actId: string; actCode: string; actStatus: string; occurredAt: string; closedAt: string | null; + visitId: string; visitCode: string; areaId: string | null; companyId: string | null; areaName: string | null; companyName: string | null; + responseDueOn: string | null; deadlineReason: string | null; responseReceivedOn: string | null; committedCorrectionOn: string | null; + findingCount: number; openFindingCount: number; scheduledControlCount: number; adminState: ActAdministrationState; +} +export interface ActAdministrationQueuePage { data: ActAdministrationQueueItem[]; counters: Partial>; meta: PageMeta } +export interface ActAdministrationDetail { + act: ActAdministrationQueueItem; + deadlines: Array<{ id:string; responseDueOn:string; reason:string; createdAt:string }>; + responses: Array<{ id:string; receivedOn:string; details:string|null; committedCorrectionOn:string|null; contactName:string|null; contactEmail:string|null; originalName:string|null; sizeBytes:number|null; sha256:string|null; createdAt:string }>; + findings: Array<{ id:string; code:string; title:string; status:string; nextControlOn:string|null }>; +} +export function listActAdministrationQueue(params: { state?: ActAdministrationState | 'ALL'; areaId?: string; companyId?: string; page?: number; pageSize?: number } = {}) { + const q = new URLSearchParams({ page: String(params.page ?? 1), pageSize: String(params.pageSize ?? 25) }); + if (params.state && params.state !== 'ALL') q.set('state', params.state); + if (params.areaId) q.set('areaId', params.areaId); + if (params.companyId) q.set('companyId', params.companyId); + return apiRequest(`/act-administration/queue?${q}`); +} +export function getActAdministration(actId: string) { return apiRequest(`/inspection-acts/${actId}/administration`); } +export function setActResponseDeadline(actId: string, input: { responseDueOn: string; reason: string }) { return apiRequest(`/inspection-acts/${actId}/administration/deadline`, { method: 'PATCH', body: JSON.stringify(input) }); } +export function addActCompanyResponse(actId: string, input: { receivedOn: string; details?: string; committedCorrectionOn?: string; contactName?: string; contactEmail?: string; file?: File }) { + const body = new FormData(); body.append('receivedOn', input.receivedOn); + if (input.details) body.append('details', input.details); if (input.committedCorrectionOn) body.append('committedCorrectionOn', input.committedCorrectionOn); + if (input.contactName) body.append('contactName', input.contactName); if (input.contactEmail) body.append('contactEmail', input.contactEmail); if (input.file) body.append('file', input.file); + return apiRequest(`/inspection-acts/${actId}/administration/responses`, { method: 'POST', body }); +} +export function actCompanyResponseContentUrl(responseId: string) { return `${API_BASE}/act-company-responses/${responseId}/content`; } +export function listActAdministrationCalendar(from?: string, to?: string) { const q=new URLSearchParams(); if(from)q.set('from',from); if(to)q.set('to',to); return apiRequest<{from:string;to:string;data:Array<{type:'RESPONSE_DUE'|'COMMITMENT_DUE';date:string;actId:string;actCode:string;areaName:string|null;companyName:string|null;state:ActAdministrationState}>}>(`/act-administration/calendar${q.size?`?${q}`:''}`); } + diff --git a/web-v2/src/pages/ActAdministrationDetailPage.tsx b/web-v2/src/pages/ActAdministrationDetailPage.tsx new file mode 100644 index 0000000..95478f1 --- /dev/null +++ b/web-v2/src/pages/ActAdministrationDetailPage.tsx @@ -0,0 +1,20 @@ + +import { useEffect, useState } from 'react'; +import type { FormEvent } from 'react'; +import { Link, useParams } from 'react-router'; +import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; +import { addActCompanyResponse, actCompanyResponseContentUrl, getActAdministration, setActResponseDeadline } from '../lib/api'; +import type { ActAdministrationDetail } from '../lib/api'; +import { formatDate } from '../lib/format'; + +export function ActAdministrationDetailPage(){ + const {actId=''}=useParams(); const [data,setData]=useState(null); const [error,setError]=useState(''); const [busy,setBusy]=useState(false); const [due,setDue]=useState(''); const [reason,setReason]=useState('Plazo administrativo otorgado a la empresa'); const [received,setReceived]=useState(new Date().toISOString().slice(0,10)); const [details,setDetails]=useState(''); const [commitment,setCommitment]=useState(''); const [file,setFile]=useState(); + const load=()=>getActAdministration(actId).then(setData).catch(e=>setError(errorMessage(e))); useEffect(()=>{void load()},[actId]); + const deadline=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await setActResponseDeadline(actId,{responseDueOn:due,reason});setDue('');await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}}; + const response=async(e:FormEvent)=>{e.preventDefault();setBusy(true);setError('');try{await addActCompanyResponse(actId,{receivedOn:received,details:details||undefined,committedCorrectionOn:commitment||undefined,file});setDetails('');setCommitment('');setFile(undefined);await load()}catch(err){setError(errorMessage(err))}finally{setBusy(false)}}; + if(!data&&!error)return ; + return
{error&&{error}}{data&&<>
EXPEDIENTE ADMINISTRATIVO

{data.act.actCode}

{data.act.areaName??'Área sin asignar'} · {data.act.companyName??'Empresa sin asignar'} · {data.act.openFindingCount} hallazgos abiertos

Ver Acta
+

Plazo de respuesta

El plazo aplica al conjunto completo de hallazgos del Acta.