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:
Generated
+2
-2
@@ -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
@@ -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> {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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"/);
|
||||
});
|
||||
Reference in New Issue
Block a user