diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 5d84a85..b646329 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-11", + "version": "0.29.0-12", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-11", + "version": "0.29.0-12", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index d96228b..c930414 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-11", + "version": "0.29.0-12", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/act-administration/act-administration.module.ts b/api-v3/src/act-administration/act-administration.module.ts index 18665e8..e7f5e73 100644 --- a/api-v3/src/act-administration/act-administration.module.ts +++ b/api-v3/src/act-administration/act-administration.module.ts @@ -18,5 +18,6 @@ import { FieldBriefingService } from './field-briefing.service'; FieldBriefingController, ], providers: [ActAdministrationService, FieldBriefingService], + exports: [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 index 38f4e2d..10785d2 100644 --- a/api-v3/src/act-administration/act-administration.service.ts +++ b/api-v3/src/act-administration/act-administration.service.ts @@ -114,17 +114,46 @@ export class ActAdministrationService { return act; } - async setDeadline(actId: string, dto: SetActResponseDeadlineDto, principal: AuthPrincipal, request: RequestWithContext) { + async setDeadline( + actId: string, + dto: SetActResponseDeadlineDto, + principal: AuthPrincipal, + request: RequestWithContext, + reportId: string | null = null, + ) { 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]; + return this.dataSource.transaction(async (manager) => { + const rows = await manager.query( + `INSERT INTO inspection_act_deadline_events (id, act_id, report_id, response_due_on, reason, created_by) + VALUES ($1,$2,$3,$4,$5,$6) + RETURNING id, report_id AS "reportId", response_due_on AS "responseDueOn", reason, created_at AS "createdAt"`, + [randomUUID(), actId, reportId, dto.responseDueOn, dto.reason, principal.userId], + ); + const projected = await manager.query( + `UPDATE inspection_findings + SET correction_due_on=$2, updated_by=$3, updated_at=CURRENT_TIMESTAMP + WHERE act_id=$1 AND status<>'VOIDED' + RETURNING id`, + [actId, dto.responseDueOn, principal.userId], + ) as Array<{ id: string }>; + 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, reportId, responseDueOn: dto.responseDueOn, reason: dto.reason }, + metadata: { reportId, projectedFindingCount: projected.length, sharedDeadline: true }, + }, manager); + return { ...rows[0], projectedFindingCount: projected.length }; + }); } - async addResponse(actId: string, dto: CreateActCompanyResponseDto, file: UploadedActResponseFile | undefined, principal: AuthPrincipal, request: RequestWithContext) { + async addResponse( + actId: string, + dto: CreateActCompanyResponseDto, + file: UploadedActResponseFile | undefined, + principal: AuthPrincipal, + request: RequestWithContext, + reportId: string | null = null, + ) { const act = await this.ensureClosedAct(actId); let storedName: string | null = null; let sha256: string | null = null; @@ -138,12 +167,12 @@ export class ActAdministrationService { } 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], + `INSERT INTO inspection_act_company_responses (id, act_id, report_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,$14) + RETURNING 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 AS "sizeBytes", sha256, created_at AS "createdAt"`, + [randomUUID(), actId, reportId, 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 } }); + 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, reportId, receivedOn: dto.receivedOn, committedCorrectionOn: dto.committedCorrectionOn ?? null, hasPdf: Boolean(file), sha256 }, metadata: { reportId } }); return rows[0]; } catch (error) { if (storedName) await unlink(join(STORAGE, storedName)).catch(() => undefined); @@ -151,8 +180,13 @@ export class ActAdministrationService { } } - 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]); + async responseContent(responseId: string, reportId?: 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 AND ($2::uuid IS NULL OR report_id=$2::uuid)`, + [responseId, reportId ?? null], + ); 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); diff --git a/api-v3/src/database/migrations/1790139000000-f6-11-report-response-workflow.ts b/api-v3/src/database/migrations/1790139000000-f6-11-report-response-workflow.ts new file mode 100644 index 0000000..e23f0c4 --- /dev/null +++ b/api-v3/src/database/migrations/1790139000000-f6-11-report-response-workflow.ts @@ -0,0 +1,43 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F611ReportResponseWorkflow1790139000000 implements MigrationInterface { + name = 'F611ReportResponseWorkflow1790139000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid`); + await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid`); + await queryRunner.query(`ALTER TABLE inspection_act_deadline_events ADD CONSTRAINT fk_act_deadline_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`); + await queryRunner.query(`ALTER TABLE inspection_act_company_responses ADD CONSTRAINT fk_act_response_report FOREIGN KEY (report_id) REFERENCES inspection_reports(id) ON DELETE RESTRICT`); + await queryRunner.query(`CREATE INDEX idx_act_deadline_events_report_created ON inspection_act_deadline_events(report_id, created_at DESC)`); + await queryRunner.query(`CREATE INDEX idx_act_company_responses_report_received ON inspection_act_company_responses(report_id, received_on DESC, created_at DESC)`); + + await queryRunner.query(` + CREATE OR REPLACE FUNCTION validate_report_act_relation() + RETURNS trigger AS $$ + BEGIN + IF NEW.report_id IS NOT NULL AND NOT EXISTS ( + SELECT 1 FROM inspection_reports report + WHERE report.id=NEW.report_id AND report.act_id=NEW.act_id + ) THEN + RAISE EXCEPTION 'El Informe no corresponde al Acta indicada'; + END IF; + RETURN NEW; + END; + $$ LANGUAGE plpgsql + `); + await queryRunner.query(`CREATE TRIGGER trg_act_deadline_report_relation BEFORE INSERT ON inspection_act_deadline_events FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`); + await queryRunner.query(`CREATE TRIGGER trg_act_response_report_relation BEFORE INSERT ON inspection_act_company_responses FOR EACH ROW EXECUTE FUNCTION validate_report_act_relation()`); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_response_report_relation ON inspection_act_company_responses`); + await queryRunner.query(`DROP TRIGGER IF EXISTS trg_act_deadline_report_relation ON inspection_act_deadline_events`); + await queryRunner.query(`DROP FUNCTION IF EXISTS validate_report_act_relation()`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_act_company_responses_report_received`); + await queryRunner.query(`DROP INDEX IF EXISTS idx_act_deadline_events_report_created`); + await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP CONSTRAINT IF EXISTS fk_act_response_report`); + await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP CONSTRAINT IF EXISTS fk_act_deadline_report`); + await queryRunner.query(`ALTER TABLE inspection_act_company_responses DROP COLUMN IF EXISTS report_id`); + await queryRunner.query(`ALTER TABLE inspection_act_deadline_events DROP COLUMN IF EXISTS report_id`); + } +} diff --git a/api-v3/src/inspection-reports/dto/set-inspection-report-response-deadline.dto.ts b/api-v3/src/inspection-reports/dto/set-inspection-report-response-deadline.dto.ts new file mode 100644 index 0000000..f59c5b6 --- /dev/null +++ b/api-v3/src/inspection-reports/dto/set-inspection-report-response-deadline.dto.ts @@ -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; +} diff --git a/api-v3/src/inspection-reports/inspection-report-workflow.service.ts b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts index f05ea68..8b8e5b1 100644 --- a/api-v3/src/inspection-reports/inspection-report-workflow.service.ts +++ b/api-v3/src/inspection-reports/inspection-report-workflow.service.ts @@ -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('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 { + 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 { const [row] = await manager.query(` SELECT id,act_id AS "actId",visit_id AS "visitId",code,status, diff --git a/api-v3/src/inspection-reports/inspection-reports.controller.ts b/api-v3/src/inspection-reports/inspection-reports.controller.ts index ebd4b15..007e69d 100644 --- a/api-v3/src/inspection-reports/inspection-reports.controller.ts +++ b/api-v3/src/inspection-reports/inspection-reports.controller.ts @@ -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 { + 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((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) { diff --git a/api-v3/src/inspection-reports/inspection-reports.module.ts b/api-v3/src/inspection-reports/inspection-reports.module.ts index a9d6852..d3a3973 100644 --- a/api-v3/src/inspection-reports/inspection-reports.module.ts +++ b/api-v3/src/inspection-reports/inspection-reports.module.ts @@ -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, diff --git a/api-v3/src/inspection-reports/inspection-reports.service.ts b/api-v3/src/inspection-reports/inspection-reports.service.ts index bdcf797..ed5dc76 100644 --- a/api-v3/src/inspection-reports/inspection-reports.service.ts +++ b/api-v3/src/inspection-reports/inspection-reports.service.ts @@ -64,6 +64,16 @@ export interface InspectionReportListItem { export interface InspectionReportView extends InspectionReportListItem { frozenSnapshot: Record; + 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 }>; - 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 { diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index ea21eb0..4bd1bea 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.29.0-11'; -export const API_PHASE = 'F6.10'; +export const API_VERSION = '0.29.0-12'; +export const API_PHASE = 'F6.11'; diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index a62e5e2..a6eb118 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { API_PHASE, API_VERSION } from '../../src/version'; -test('health metadata reports the current F6.10 release', () => { - assert.equal(API_PHASE, 'F6.10'); +test('health metadata reports the current F6.11 release', () => { + assert.equal(API_PHASE, 'F6.11'); const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; assert.equal(API_VERSION, pkg.version); - assert.equal(API_VERSION, '0.29.0-11'); + assert.equal(API_VERSION, '0.29.0-12'); }); diff --git a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts index f234652..b2cac9a 100644 --- a/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts +++ b/api-v3/test/unit/f6-1-presentation-ready-contract.test.ts @@ -13,7 +13,7 @@ test('F6.1 presentation metadata keeps the visible WEB version aligned with pack const visibleVersion = version.match(/APP_VERSION\s*=\s*'([^']+)'/)?.[1]; assert.equal(visibleVersion, pkg.version); - assert.match(version, /APP_PHASE\s*=\s*'F6\.9/); + assert.match(version, /APP_PHASE\s*=\s*'F6\.11/); }); test('F6.1 presentation keeps Relevamientos retired from WEB navigation and routes', () => { diff --git a/api-v3/test/unit/f6-11-report-gedo-response-workflow.test.ts b/api-v3/test/unit/f6-11-report-gedo-response-workflow.test.ts new file mode 100644 index 0000000..5cf6907 --- /dev/null +++ b/api-v3/test/unit/f6-11-report-gedo-response-workflow.test.ts @@ -0,0 +1,56 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const api = (path: string) => readFileSync(resolve(process.cwd(), 'src', path), 'utf8'); +const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', 'src', path), 'utf8'); + +test('F6.11 keeps GEDO officialization manual and does not activate response deadlines automatically', () => { + const workflow = api('inspection-reports/inspection-report-workflow.service.ts'); + const page = web('pages/ReportDetailPage.tsx'); + assert.match(workflow, /GEDO oficializa el INF, pero no equivale por sí solo a la notificación/); + assert.doesNotMatch(workflow.slice(workflow.indexOf(' async officialize('), workflow.indexOf(' async setResponseDeadline(')), /setDeadline\(/); + assert.match(page, /GEDO no se consulta automáticamente/); + assert.match(page, /no crea respuestas ni vencimientos automáticamente/); +}); + +test('F6.11 stores new deadlines and company responses with both Report and Act relation', () => { + const migration = api('database/migrations/1790139000000-f6-11-report-response-workflow.ts'); + const administration = api('act-administration/act-administration.service.ts'); + assert.match(migration, /inspection_act_deadline_events ADD COLUMN IF NOT EXISTS report_id uuid/); + assert.match(migration, /inspection_act_company_responses ADD COLUMN IF NOT EXISTS report_id uuid/); + assert.match(migration, /report\.id=NEW\.report_id AND report\.act_id=NEW\.act_id/); + assert.doesNotMatch(migration, /UPDATE inspection_act_deadline_events|UPDATE inspection_act_company_responses/); + assert.match(administration, /INSERT INTO inspection_act_deadline_events \(id, act_id, report_id/); + assert.match(administration, /INSERT INTO inspection_act_company_responses \(id, act_id, report_id/); +}); + +test('F6.11 projects one Act response deadline to every non-voided finding', () => { + const administration = api('act-administration/act-administration.service.ts'); + const reports = api('inspection-reports/inspection-reports.service.ts'); + assert.match(administration, /UPDATE inspection_findings[\s\S]*SET correction_due_on=\$2[\s\S]*WHERE act_id=\$1 AND status<>'VOIDED'/); + assert.match(administration, /sharedDeadline: true/); + assert.match(reports, /\$2::date AS "responseDueOn"/); + assert.match(reports, /currentDeadline\?\.responseDueOn/); +}); + +test('F6.11 enables formal responses only after the official GEDO PDF exists', () => { + const workflow = api('inspection-reports/inspection-report-workflow.service.ts'); + const controller = api('inspection-reports/inspection-reports.controller.ts'); + const page = web('pages/ReportDetailPage.tsx'); + assert.match(workflow, /requireOfficializedReport\(reportId\)/); + assert.match(workflow, /Primero debe cargarse manualmente el PDF oficial de GEDO/); + assert.match(controller, /@Post\(':id\/company-responses'\)/); + assert.match(controller, /@Patch\(':id\/response-deadline'\)/); + assert.match(page, /Las respuestas se habilitan después de cargar el PDF oficial de GEDO/); + assert.match(page, /Vencimiento común del Acta/); +}); + +test('F6.11 separates formal company responses from internal report follow-up notes', () => { + const page = web('pages/ReportDetailPage.tsx'); + assert.match(page, /RESPUESTAS DE EMPRESA/); + assert.match(page, /No usar este bloque para respuestas formales de empresa/); + assert.match(page, /