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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.29.0-11",
"version": "0.29.0-12",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -18,5 +18,6 @@ import { FieldBriefingService } from './field-briefing.service';
FieldBriefingController,
],
providers: [ActAdministrationService, FieldBriefingService],
exports: [ActAdministrationService],
})
export class ActAdministrationModule {}
@@ -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);
@@ -0,0 +1,43 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F611ReportResponseWorkflow1790139000000 implements MigrationInterface {
name = 'F611ReportResponseWorkflow1790139000000';
public async up(queryRunner: QueryRunner): Promise<void> {
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<void> {
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`);
}
}
@@ -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> {
+2 -2
View File
@@ -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';
+3 -3
View File
@@ -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');
});
@@ -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', () => {
@@ -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, /<option value="INTERNAL_NOTE">Nota interna<\/option>/);
assert.doesNotMatch(page.slice(page.indexOf('Agregar otro antecedente')), /option value="COMPANY_NOTE"/);
});