From 0279c22e20f68aa8e6b2ca5d72998c1d58a89a34 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Sat, 5 Sep 2026 18:09:17 +0000 Subject: [PATCH] feat: F1.2 materialized act administration --- .f12-materialize/materialize.py.gz | Bin 11065 -> 0 bytes .github/workflows/f1-2-materialize.yml | 33 ---- 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 | 23 +++ 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 +++ 22 files changed, 454 insertions(+), 44 deletions(-) delete mode 100644 .f12-materialize/materialize.py.gz delete mode 100644 .github/workflows/f1-2-materialize.yml 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/.f12-materialize/materialize.py.gz b/.f12-materialize/materialize.py.gz deleted file mode 100644 index 137119a4d8c8a83ef49f8572b248c16db975b60c..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 11065 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 diff --git a/.github/workflows/f1-2-materialize.yml b/.github/workflows/f1-2-materialize.yml deleted file mode 100644 index fa30a0a..0000000 --- a/.github/workflows/f1-2-materialize.yml +++ /dev/null @@ -1,33 +0,0 @@ -name: Materialize F1.2 - -on: - push: - branches: - - feature/f1-2-act-admin - -permissions: - contents: write - -jobs: - materialize: - if: "!contains(github.event.head_commit.message, 'feat: F1.2 materialized')" - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: feature/f1-2-act-admin - fetch-depth: 0 - - name: Materialize F1.2 source - shell: bash - run: | - set -euo pipefail - gunzip -c .f12-materialize/materialize.py.gz > /tmp/materialize_f12.py - echo "4535edfe30e1f78608c3d247303a66f8bd1013558119a87c1ca6b3d0a09d88c0 /tmp/materialize_f12.py" | sha256sum -c - - python /tmp/materialize_f12.py - rm -rf .f12-materialize - rm -f .github/workflows/f1-2-materialize.yml - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git add -A - git commit -m "feat: F1.2 materialized act administration" - git push origin HEAD:feature/f1-2-act-admin 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..2285935 --- /dev/null +++ b/api-v3/test/unit/phase-f1-2-act-administration.test.ts @@ -0,0 +1,23 @@ + +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +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 migration = readFileSync(new URL('../../src/database/migrations/1789581600000-phase-f1-2-act-administration.ts', import.meta.url), 'utf8'); +const service = readFileSync(new URL('../../src/act-administration/act-administration.service.ts', import.meta.url), 'utf8'); +const controller = readFileSync(new URL('../../src/act-administration/act-administration.controller.ts', import.meta.url), 'utf8'); + +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.