From 416aac335c0e99b40e2729e10a038e9359c8d17b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 20:56:11 -0300 Subject: [PATCH] refactor(f4): make INF editable and one-to-one with sealed Act --- .../inspection-reports.service.ts | 407 ++++++++---------- 1 file changed, 178 insertions(+), 229 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-reports.service.ts b/api-v3/src/inspection-reports/inspection-reports.service.ts index 4d7d13f..83a7f36 100644 --- a/api-v3/src/inspection-reports/inspection-reports.service.ts +++ b/api-v3/src/inspection-reports/inspection-reports.service.ts @@ -5,7 +5,6 @@ import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { AuditAction, - DocumentSequenceType, InspectionActStatus, InspectionReportPdfStatus, InspectionReportStatus, @@ -16,7 +15,7 @@ import type { ListInspectionReportsQueryDto } from './dto/list-inspection-report import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; -const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1'; +const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-INF-V4'; interface ContextAsset { id: string; @@ -32,20 +31,31 @@ export interface InspectionReportListItem { reportNumber: number; code: string; status: InspectionReportStatus; + executiveSummary: string | null; + reportDescription: string | null; + gedoIfIdentifier: string | null; + gedoOfficializedAt: Date | null; + gedoPdfOriginalName: string | null; + gedoPdfSha256: string | null; pdfStatus: InspectionReportPdfStatus; wordStatus: 'PENDING' | 'READY' | 'FAILED'; wordGeneratedAt: Date | null; - reviewStatus: 'PENDING_REVIEW' | 'APPROVED' | 'SIGNED'; - currentRevisionNumber: number; - approvedAt: Date | null; - signedAt: Date | null; title: string; actVersion: number; actClosureSha256: string; frozenSha256: string; generatedAt: Date; generatedBy: { id: string; username: string; firstName: string; lastName: string }; - act: { id: string; code: string; title: string; status: string; occurredAt: Date; closedAt: Date | null }; + act: { + id: string; + code: string; + title: string; + status: string; + occurredAt: Date; + sealedAt: Date | null; + urgency: string; + deadlineAt: Date | null; + }; visit: { id: string; code: string; title: string; status: string }; companies: ContextAsset[]; areas: ContextAsset[]; @@ -63,7 +73,7 @@ export interface PendingInspectionReportItem { actTitle: string; actYear: number; occurredAt: Date; - closedAt: Date; + sealedAt: Date; closureSha256: string; visitCode: string; visitTitle: string; @@ -127,7 +137,7 @@ export class InspectionReportsService { async listPending(query: ListInspectionReportsQueryDto) { const conditions = [ - `act.status = 'CLOSED'`, + `act.status = 'SEALED'`, 'report.id IS NULL', 'act.closure_sha256 IS NOT NULL', ]; @@ -143,21 +153,6 @@ export class InspectionReportsService { OR act.title ILIKE ${search} OR visit.code ILIKE ${search} OR visit.title ILIKE ${search} - OR EXISTS ( - SELECT 1 - FROM inspection_act_assets search_link - INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id - LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id - LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id - WHERE search_link.act_id = act.id - AND search_link.included = true - AND ( - search_asset.name ILIKE ${search} - OR search_asset.code ILIKE ${search} - OR search_company.name ILIKE ${search} - OR search_area.name ILIKE ${search} - ) - ) )`); } if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); @@ -196,7 +191,7 @@ export class InspectionReportsService { act.title AS "actTitle", act.act_year AS "actYear", act.occurred_at AS "occurredAt", - act.closed_at AS "closedAt", + act.sealed_at AS "sealedAt", act.closure_sha256 AS "closureSha256", visit.code AS "visitCode", visit.title AS "visitTitle", @@ -226,16 +221,7 @@ export class InspectionReportsService { } async get(id: string): Promise { - const [report] = (await this.dataSource.query( - `${this.reportSelect('WHERE report.id = $1')}`, - [id], - )) as InspectionReportListItem[]; - if (!report) throw reportNotFound(); - const [snapshot] = (await this.dataSource.query( - 'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1', - [id], - )) as Array<{ frozenSnapshot: Record }>; - return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; + return this.getWithManager(this.dataSource.manager, id); } async generate( @@ -244,144 +230,146 @@ export class InspectionReportsService { request: RequestWithContext, ): Promise { assertMobileInspector(principal); - const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request)); + const report = await this.dataSource.transaction((manager) => + this.ensureFrozenReport(manager, actId, principal, request), + ); await this.word.ensure(report.id); return this.get(report.id); } + /** + * Conserva una copia interna inmutable del Acta sellada como fuente documental. + * El INF que se genera desde esa fuente permanece editable y versionable. + */ async ensureFrozenReport( manager: EntityManager, actId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { - const [act] = (await manager.query(` - SELECT - act.id, - act.visit_id AS "visitId", - act.act_year AS "actYear", - act.code, - act.title, - act.status, - act.current_version AS "currentVersion", - act.closure_sha256 AS "closureSha256", - visit.code AS "visitCode", - visit.title AS "visitTitle" - FROM inspection_acts act - INNER JOIN inspection_visits visit ON visit.id = act.visit_id - WHERE act.id = $1 - FOR UPDATE OF act - `, [actId])) as Array<{ - id: string; - visitId: string; - actYear: number; - code: string; - title: string; - status: InspectionActStatus; - currentVersion: number; - closureSha256: string | null; - visitCode: string; - visitTitle: string; - }>; - if (!act) throw actNotFound(); - if (act.status !== InspectionActStatus.CLOSED || !act.closureSha256) { - throw new ConflictException({ - code: 'INSPECTION_REPORT_ACT_NOT_CLOSED', - message: 'El informe sólo puede emitirse después del cierre definitivo del acta', - }); - } - await this.assertActorAssigned(manager, act.visitId, principal.userId); - const [existing] = (await manager.query( - 'SELECT id FROM inspection_reports WHERE act_id = $1', - [actId], - )) as Array<{ id: string }>; - if (existing) return this.getWithManager(manager, existing.id); - const [closure] = (await manager.query(` - SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256" - FROM inspection_act_closures - WHERE act_id = $1 - `, [actId])) as Array<{ - finalSnapshot: Record | null; - finalSha256: string | null; - }>; - if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) { - throw new ConflictException({ - code: 'INSPECTION_REPORT_CLOSURE_NOT_FROZEN', - message: 'El cierre del acta no tiene una instantánea final válida para emitir el informe', - }); - } - const reportNumber = await this.allocateNumber(manager, act.actYear); - if (reportNumber > 999999) { - throw new ConflictException({ - code: 'INSPECTION_REPORT_SEQUENCE_EXHAUSTED', - message: 'La numeración anual de informes agotó su rango disponible', - }); - } - const code = `INF-${act.actYear}-${String(reportNumber).padStart(6, '0')}`; - const generatedAt = new Date(); - const title = `Informe técnico · ${act.title}`.slice(0, 220); - const frozenSnapshot = { - schemaVersion: REPORT_SCHEMA_VERSION, - report: { - code, - reportYear: act.actYear, - reportNumber, - generatedAt: generatedAt.toISOString(), - generatedBy: principal.userId, - generatedByUsername: principal.username, - visitId: act.visitId, - visitCode: act.visitCode, - visitTitle: act.visitTitle, - actId: act.id, - actCode: act.code, - actVersion: act.currentVersion, - actClosureSha256: act.closureSha256, - }, - actClosure: closure.finalSnapshot, - }; - const frozenSha256 = sha256CanonicalJson(frozenSnapshot); - const [created] = (await manager.query(` - INSERT INTO inspection_reports ( - visit_id, act_id, report_year, report_number, code, status, pdf_status, - title, act_version, act_closure_sha256, frozen_sha256, frozen_snapshot, - generated_at, generated_by - ) VALUES ( - $1, $2, $3, $4, $5, 'FROZEN', 'PENDING', $6, $7, $8, $9, $10, $11, $12 - ) - RETURNING id - `, [ - act.visitId, + const [act] = (await manager.query(` + SELECT act.id, - act.actYear, - reportNumber, + act.visit_id AS "visitId", + act.act_year AS "actYear", + act.act_number AS "actNumber", + act.code, + act.title, + act.status, + act.current_version AS "currentVersion", + act.closure_sha256 AS "closureSha256", + act.occurred_at AS "occurredAt", + visit.code AS "visitCode", + visit.title AS "visitTitle" + FROM inspection_acts act + INNER JOIN inspection_visits visit ON visit.id = act.visit_id + WHERE act.id = $1 + FOR UPDATE OF act + `, [actId])) as Array<{ + id: string; + visitId: string; + actYear: number; + actNumber: number; + code: string; + title: string; + status: InspectionActStatus; + currentVersion: number; + closureSha256: string | null; + occurredAt: Date; + visitCode: string; + visitTitle: string; + }>; + if (!act) throw actNotFound(); + if (act.status !== InspectionActStatus.SEALED || !act.closureSha256) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ACT_NOT_SEALED', + message: 'El INF sólo puede generarse después de sellar el acta', + }); + } + await this.assertActorAssigned(manager, act.visitId, principal.userId); + const [existing] = (await manager.query( + 'SELECT id FROM inspection_reports WHERE act_id = $1', + [actId], + )) as Array<{ id: string }>; + if (existing) return this.getWithManager(manager, existing.id); + + const [closure] = (await manager.query(` + SELECT final_snapshot AS "finalSnapshot", final_sha256 AS "finalSha256" + FROM inspection_act_closures + WHERE act_id = $1 + `, [actId])) as Array<{ + finalSnapshot: Record | null; + finalSha256: string | null; + }>; + if (!closure?.finalSnapshot || closure.finalSha256 !== act.closureSha256) { + throw new ConflictException({ + code: 'INSPECTION_REPORT_ACT_SNAPSHOT_INVALID', + message: 'El acta sellada no tiene una instantánea final válida', + }); + } + + const reportNumber = act.actNumber; + const code = act.code.replace(/^ACT-/, 'INF-'); + const generatedAt = new Date(); + const title = `Informe de inspección · ${act.code}`.slice(0, 220); + const frozenSnapshot = { + schemaVersion: REPORT_SCHEMA_VERSION, + source: { + actId: act.id, + actCode: act.code, + actVersion: act.currentVersion, + actClosureSha256: act.closureSha256, + inspectionId: act.visitId, + inspectionCode: act.visitCode, + }, + sealedAct: closure.finalSnapshot, + }; + const frozenSha256 = sha256CanonicalJson(frozenSnapshot); + const [created] = (await manager.query(` + INSERT INTO inspection_reports ( + visit_id,act_id,report_year,report_number,code,status,pdf_status, + executive_summary,report_description,title,act_version,act_closure_sha256, + frozen_sha256,frozen_snapshot,generated_at,generated_by + ) VALUES ( + $1,$2,$3,$4,$5,'WORKING','PENDING',NULL,NULL,$6,$7,$8,$9,$10,$11,$12 + ) + RETURNING id + `, [ + act.visitId, + act.id, + act.actYear, + reportNumber, + code, + title, + act.currentVersion, + act.closureSha256, + frozenSha256, + frozenSnapshot, + generatedAt, + principal.userId, + ])) as Array<{ id: string }>; + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.INSPECTION_REPORT_GENERATED, + entityType: 'inspection_report', + entityId: created.id, + afterData: { code, - title, - act.currentVersion, - act.closureSha256, + actId: act.id, + inspectionId: act.visitId, + actVersion: act.currentVersion, frozenSha256, - frozenSnapshot, - generatedAt, - principal.userId, - ])) as Array<{ id: string }>; - await this.audit.record({ - ...administrationAuditContext(principal, request), - action: AuditAction.INSPECTION_REPORT_GENERATED, - entityType: 'inspection_report', - entityId: created.id, - afterData: { - code, - actId: act.id, - visitId: act.visitId, - actVersion: act.currentVersion, - frozenSha256, - pdfStatus: InspectionReportPdfStatus.PENDING, - }, - }, manager); - return this.getWithManager(manager, created.id); + status: InspectionReportStatus.WORKING, + }, + }, manager); + return this.getWithManager(manager, created.id); } async ensureWordForAct(actId: string): Promise { - const [row] = await this.dataSource.query('SELECT id FROM inspection_reports WHERE act_id = $1', [actId]) as Array<{ id: string }>; + const [row] = await this.dataSource.query( + 'SELECT id FROM inspection_reports WHERE act_id = $1', + [actId], + ) as Array<{ id: string }>; if (row) await this.word.ensure(row.id); await this.delivery.dispatchForAct(actId).catch(() => undefined); } @@ -398,25 +386,11 @@ export class InspectionReportsService { conditions.push(`( report.code ILIKE ${search} OR report.title ILIKE ${search} + OR report.gedo_if_identifier ILIKE ${search} OR act.code ILIKE ${search} OR act.title ILIKE ${search} OR visit.code ILIKE ${search} OR visit.title ILIKE ${search} - OR EXISTS ( - SELECT 1 - FROM inspection_act_assets search_link - INNER JOIN assets search_asset ON search_asset.id = search_link.asset_id - LEFT JOIN assets search_company ON search_company.id = search_asset.operator_company_id - LEFT JOIN assets search_area ON search_area.id = search_asset.operational_area_id - WHERE search_link.act_id = act.id - AND search_link.included = true - AND ( - search_asset.name ILIKE ${search} - OR search_asset.code ILIKE ${search} - OR search_company.name ILIKE ${search} - OR search_area.name ILIKE ${search} - ) - ) )`); } if (query.year) conditions.push(`report.report_year = ${add(query.year)}`); @@ -454,20 +428,16 @@ export class InspectionReportsService { return ` SELECT COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id', company.id, - 'code', company.code, - 'name', company.name - )) FILTER (WHERE company.id IS NOT NULL), '[]'::jsonb) AS companies, + 'id',company.id,'code',company.code,'name',company.name + )) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies, COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id', area.id, - 'code', area.code, - 'name', area.name - )) FILTER (WHERE area.id IS NOT NULL), '[]'::jsonb) AS areas + 'id',area.id,'code',area.code,'name',area.name + )) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas FROM inspection_act_assets context_link - INNER JOIN assets context_asset ON context_asset.id = context_link.asset_id - LEFT JOIN assets company ON company.id = context_asset.operator_company_id - LEFT JOIN assets area ON area.id = context_asset.operational_area_id - WHERE context_link.act_id = act.id AND context_link.included = true + INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id + LEFT JOIN assets company ON company.id=context_asset.operator_company_id + LEFT JOIN assets area ON area.id=context_asset.operational_area_id + WHERE context_link.act_id=act.id AND context_link.included=true `; } @@ -481,50 +451,43 @@ export class InspectionReportsService { report.report_number AS "reportNumber", report.code, report.status, + report.executive_summary AS "executiveSummary", + report.report_description AS "reportDescription", + report.gedo_if_identifier AS "gedoIfIdentifier", + report.gedo_officialized_at AS "gedoOfficializedAt", + report.gedo_pdf_original_name AS "gedoPdfOriginalName", + report.gedo_pdf_sha256 AS "gedoPdfSha256", report.pdf_status AS "pdfStatus", report.word_status AS "wordStatus", report.word_generated_at AS "wordGeneratedAt", - report.review_status AS "reviewStatus", - report.current_revision_number AS "currentRevisionNumber", - report.approved_at AS "approvedAt", - report.signed_at AS "signedAt", report.title, report.act_version AS "actVersion", report.act_closure_sha256 AS "actClosureSha256", report.frozen_sha256 AS "frozenSha256", report.generated_at AS "generatedAt", JSONB_BUILD_OBJECT( - 'id', generator.id, - 'username', generator.username, - 'firstName', generator.first_name, - 'lastName', generator.last_name + 'id',generator.id,'username',generator.username, + 'firstName',generator.first_name,'lastName',generator.last_name ) AS "generatedBy", JSONB_BUILD_OBJECT( - 'id', act.id, - 'code', act.code, - 'title', act.title, - 'status', act.status, - 'occurredAt', act.occurred_at, - 'closedAt', act.closed_at + 'id',act.id,'code',act.code,'title',act.title,'status',act.status, + 'occurredAt',act.occurred_at,'sealedAt',act.sealed_at, + 'urgency',act.urgency,'deadlineAt',act.deadline_at ) AS act, JSONB_BUILD_OBJECT( - 'id', visit.id, - 'code', visit.code, - 'title', visit.title, - 'status', visit.status + 'id',visit.id,'code',visit.code,'title',visit.title,'status',visit.status ) AS visit, - COALESCE(context.companies, '[]'::jsonb) AS companies, - COALESCE(context.areas, '[]'::jsonb) AS areas, - COALESCE(finding_count.total, 0)::integer AS "findingCount" + COALESCE(context.companies,'[]'::jsonb) AS companies, + COALESCE(context.areas,'[]'::jsonb) AS areas, + COALESCE(finding_count.total,0)::integer AS "findingCount" FROM inspection_reports report - INNER JOIN inspection_acts act ON act.id = report.act_id - INNER JOIN inspection_visits visit ON visit.id = report.visit_id - INNER JOIN users generator ON generator.id = report.generated_by + INNER JOIN inspection_acts act ON act.id=report.act_id + INNER JOIN inspection_visits visit ON visit.id=report.visit_id + INNER JOIN users generator ON generator.id=report.generated_by LEFT JOIN LATERAL (${this.contextSelect()}) context ON true LEFT JOIN LATERAL ( - SELECT COUNT(*) AS total - FROM inspection_findings finding - WHERE finding.act_id = act.id AND finding.status <> 'VOIDED' + SELECT COUNT(*) AS total FROM inspection_findings finding + WHERE finding.act_id=act.id AND finding.status<>'VOIDED' ) finding_count ON true ${where} `; @@ -543,29 +506,15 @@ export class InspectionReportsService { return { ...report, frozenSnapshot: snapshot.frozenSnapshot }; } - private async allocateNumber(manager: EntityManager, year: number): Promise { - const [row] = (await manager.query(` - INSERT INTO document_annual_sequences (document_type, year, last_number) - VALUES ($1, $2, 1) - ON CONFLICT (document_type, year) DO UPDATE SET - last_number = document_annual_sequences.last_number + 1, - updated_at = CURRENT_TIMESTAMP - RETURNING last_number AS number - `, [DocumentSequenceType.REPORT, year])) as Array<{ number: number }>; - return Number(row.number); - } - private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise { const rows = (await manager.query(` - SELECT 1 - FROM inspection_visit_members - WHERE visit_id = $1 AND user_id = $2 AND included = true - LIMIT 1 + SELECT 1 FROM inspection_visit_members + WHERE visit_id=$1 AND user_id=$2 AND included=true LIMIT 1 `, [visitId, userId])) as unknown[]; - if (rows.length === 0) { + if (!rows.length) { throw new ForbiddenException({ code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED', - message: 'Sólo un inspector asignado a la visita puede solicitar el informe', + message: 'Sólo un inspector asignado a la inspección puede solicitar el informe', }); } }