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
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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user