feat(informes): manage manual GEDO responses and shared due date
DH V2 CI / WEB · typecheck, build (push) Successful in 27s
Production dependency audit / API · production dependencies (push) Successful in 15s
Production dependency audit / WEB · production dependencies (push) Successful in 15s
DH V2 CI / API · typecheck, tests, build (push) Successful in 2m3s
DH V2 CI / Docker / migrations / production images (push) Successful in 2m58s
DH V2 CI / Promote verified main to deploy (push) Successful in 3s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Successful in 5m55s

This commit is contained in:
2026-09-15 23:35:49 -03:00
parent 69aeb52040
commit c9575cc520
20 changed files with 485 additions and 161 deletions
@@ -0,0 +1,14 @@
import { Transform } from 'class-transformer';
import { IsDateString, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
export class SetInspectionReportResponseDeadlineDto {
@IsDateString()
responseDueOn!: string;
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() || null : value)
@IsString()
@MinLength(3)
@MaxLength(1000)
reason?: string | null;
}
@@ -11,6 +11,8 @@ import {
import { ConfigService } from '@nestjs/config';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { ActAdministrationService, type UploadedActResponseFile } from '../act-administration/act-administration.service';
import type { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
@@ -20,6 +22,7 @@ import {
import type { CreateInspectionReportFollowUpDto } from './dto/create-inspection-report-follow-up.dto';
import type { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import type { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import type { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
export const MAX_INSPECTION_REPORT_FILE_BYTES = 40 * 1024 * 1024;
@@ -56,6 +59,7 @@ export class InspectionReportWorkflowService {
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
private readonly administration: ActAdministrationService,
config: ConfigService,
) {
const configured = config.get<string>('INSPECTION_REPORT_UPLOAD_ROOT')
@@ -220,6 +224,42 @@ export class InspectionReportWorkflowService {
}
}
async setResponseDeadline(
reportId: string,
dto: SetInspectionReportResponseDeadlineDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const report = await this.requireOfficializedReport(reportId);
const reason = dto.reason ?? `Vencimiento general de respuestas definido desde el Informe ${report.code}`;
await this.administration.setDeadline(
report.actId,
{ responseDueOn: dto.responseDueOn, reason },
principal,
request,
report.id,
);
return this.getWorkflowView(this.dataSource.manager, reportId);
}
async addCompanyResponse(
reportId: string,
dto: CreateActCompanyResponseDto,
file: UploadedActResponseFile | undefined,
principal: AuthPrincipal,
request: RequestWithContext,
) {
const report = await this.requireOfficializedReport(reportId);
await this.administration.addResponse(report.actId, dto, file, principal, request, report.id);
return this.getWorkflowView(this.dataSource.manager, reportId);
}
async companyResponseContent(reportId: string, responseId: string) {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
return this.administration.responseContent(responseId, reportId);
}
async officialPdfContent(reportId: string): Promise<{ filePath: string; originalName: string; mimeType: string }> {
const [row] = await this.dataSource.query(`
SELECT
@@ -300,6 +340,15 @@ export class InspectionReportWorkflowService {
try {
return await this.dataSource.transaction(async (manager) => {
const report = await this.lockReport(manager, reportId);
if (
(dto.type === 'COMPANY_NOTE' || dto.type === 'COMPANY_DOCUMENT')
&& report.status !== InspectionReportStatus.OFFICIALIZED
) {
throw new ConflictException({
code: 'INSPECTION_REPORT_GEDO_REQUIRED_FOR_COMPANY_RESPONSE',
message: 'Las respuestas de la empresa se registran después de cargar el PDF oficial de GEDO',
});
}
const occurredAt = new Date(dto.occurredAt);
await manager.query(`
INSERT INTO inspection_report_follow_ups (
@@ -407,6 +456,18 @@ export class InspectionReportWorkflowService {
return /^\.[a-z0-9]{1,10}$/.test(ext) ? ext : '';
}
private async requireOfficializedReport(reportId: string): Promise<ReportRow> {
const report = await this.getReport(this.dataSource.manager, reportId);
if (!report) throw reportNotFound();
if (report.status !== InspectionReportStatus.OFFICIALIZED) {
throw new ConflictException({
code: 'INSPECTION_REPORT_GEDO_REQUIRED',
message: 'Primero debe cargarse manualmente el PDF oficial de GEDO',
});
}
return report;
}
private async lockReport(manager: EntityManager, id: string): Promise<ReportRow> {
const [row] = await manager.query(`
SELECT id,act_id AS "actId",visit_id AS "visitId",code,status,
@@ -15,6 +15,8 @@ import {
} from '@nestjs/common';
import { FileInterceptor } from '@nestjs/platform-express';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { CreateActCompanyResponseDto } from '../act-administration/dto/create-act-company-response.dto';
import { MAX_ACT_RESPONSE_BYTES, type UploadedActResponseFile } from '../act-administration/act-administration.service';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { Response } from 'express';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
@@ -22,6 +24,7 @@ import { CreateInspectionReportFollowUpDto } from './dto/create-inspection-repor
import { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto';
import { OfficializeInspectionReportDto } from './dto/officialize-inspection-report.dto';
import { UpdateInspectionReportDto } from './dto/update-inspection-report.dto';
import { SetInspectionReportResponseDeadlineDto } from './dto/set-inspection-report-response-deadline.dto';
import {
InspectionReportWorkflowService,
MAX_INSPECTION_REPORT_FILE_BYTES,
@@ -136,6 +139,49 @@ export class InspectionReportsController {
return this.workflow.officialize(id, dto, file, principal, request);
}
@Patch(':id/response-deadline')
@RequirePermissions('inspection_reports.generate')
setResponseDeadline(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: SetInspectionReportResponseDeadlineDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.setResponseDeadline(id, dto, principal, request);
}
@Post(':id/company-responses')
@RequirePermissions('inspection_reports.generate')
@UseInterceptors(FileInterceptor('file', {
limits: { fileSize: MAX_ACT_RESPONSE_BYTES, files: 1 },
}))
addCompanyResponse(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Body() dto: CreateActCompanyResponseDto,
@UploadedFile() file: UploadedActResponseFile | undefined,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.workflow.addCompanyResponse(id, dto, file, principal, request);
}
@Get(':id/company-responses/:responseId/content')
@RequirePermissions('inspection_reports.read')
async companyResponseContent(
@Param('id', new ParseUUIDPipe({ version: '4' })) id: string,
@Param('responseId', new ParseUUIDPipe({ version: '4' })) responseId: string,
@Res() response: Response,
): Promise<void> {
const item = await this.workflow.companyResponseContent(id, responseId);
const safeName = 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', `attachment; filename="${safeName}"; filename*=UTF-8''${encodeURIComponent(item.originalName)}`);
response.setHeader('Cache-Control', 'private, no-store');
response.setHeader('X-Content-Type-Options', 'nosniff');
await new Promise<void>((resolveSend, rejectSend) => response.sendFile(item.filePath, (error) => error ? rejectSend(error) : resolveSend()));
}
@Get(':id/follow-ups')
@RequirePermissions('inspection_reports.read')
followUps(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) {
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { AuditModule } from '../audit/audit.module';
import { ActAdministrationModule } from '../act-administration/act-administration.module';
import { DocumentDeliveryController } from './document-delivery.controller';
import { InspectionActPdfService } from './inspection-act-pdf.service';
import { InspectionDeadlineAdminController } from './inspection-deadline-admin.controller';
@@ -12,7 +13,7 @@ import { InspectionReportsService } from './inspection-reports.service';
import { SmtpDeliveryService } from './smtp-delivery.service';
@Module({
imports: [AuditModule],
imports: [AuditModule, ActAdministrationModule],
controllers: [
InspectionReportsController,
InspectionActReportController,
@@ -64,6 +64,16 @@ export interface InspectionReportListItem {
export interface InspectionReportView extends InspectionReportListItem {
frozenSnapshot: Record<string, unknown>;
responseDueOn: string | null;
deadlineReason: string | null;
deadlines: Array<{ id: string; reportId: string | null; responseDueOn: string; reason: string; createdAt: Date }>;
companyResponses: Array<{
id: string; reportId: string | null; receivedOn: string; details: string | null; committedCorrectionOn: string | null;
contactName: string | null; contactEmail: string | null; originalName: string | null; sizeBytes: number | null; sha256: string | null; createdAt: Date;
}>;
findings: Array<{
id: string; code: string; title: string; status: string; assetCode: string; assetName: string; responseDueOn: string | null;
}>;
}
export interface PendingInspectionReportItem {
@@ -491,7 +501,38 @@ export class InspectionReportsService {
'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1',
[id],
)) as Array<{ frozenSnapshot: Record<string, unknown> }>;
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
const deadlines = await manager.query(`
SELECT id,report_id AS "reportId",response_due_on AS "responseDueOn",reason,created_at AS "createdAt"
FROM inspection_act_deadline_events
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
ORDER BY created_at DESC,id DESC
`, [report.actId, report.id]) as InspectionReportView['deadlines'];
const currentDeadline = deadlines[0] ?? null;
const companyResponses = await manager.query(`
SELECT id,report_id AS "reportId",received_on AS "receivedOn",details,
committed_correction_on AS "committedCorrectionOn",contact_name AS "contactName",contact_email AS "contactEmail",
original_name AS "originalName",size_bytes::integer AS "sizeBytes",sha256,created_at AS "createdAt"
FROM inspection_act_company_responses
WHERE act_id=$1 AND (report_id IS NULL OR report_id=$2)
ORDER BY received_on DESC,created_at DESC,id DESC
`, [report.actId, report.id]) as InspectionReportView['companyResponses'];
const findings = await manager.query(`
SELECT finding.id,finding.code,finding.title,finding.status,asset.code AS "assetCode",asset.name AS "assetName",
$2::date AS "responseDueOn"
FROM inspection_findings finding
JOIN assets asset ON asset.id=finding.asset_id
WHERE finding.act_id=$1 AND finding.status<>'VOIDED'
ORDER BY finding.finding_number,finding.id
`, [report.actId, currentDeadline?.responseDueOn ?? null]) as InspectionReportView['findings'];
return {
...report,
frozenSnapshot: snapshot.frozenSnapshot,
responseDueOn: currentDeadline?.responseDueOn ?? null,
deadlineReason: currentDeadline?.reason ?? null,
deadlines,
companyResponses,
findings,
};
}
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {