import { ConflictException, ForbiddenException, Injectable, NotFoundException } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { administrationAuditContext } from '../administration/common/administration-audit'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { AuditAction, DocumentSequenceType, InspectionActStatus, InspectionReportPdfStatus, InspectionReportStatus, } from '../database/entities'; import { sha256CanonicalJson } from '../inspection-closing/canonical-json'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import type { ListInspectionReportsQueryDto } from './dto/list-inspection-reports-query.dto'; import { InspectionReportWordService } from './inspection-report-word.service'; import { InspectionDocumentDeliveryService } from './inspection-document-delivery.service'; const REPORT_SCHEMA_VERSION = 'DH-INSPECTION-REPORT-V1'; interface ContextAsset { id: string; code: string; name: string; } export interface InspectionReportListItem { id: string; visitId: string; actId: string; reportYear: number; reportNumber: number; code: string; status: InspectionReportStatus; 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 }; visit: { id: string; code: string; title: string; status: string }; companies: ContextAsset[]; areas: ContextAsset[]; findingCount: number; } export interface InspectionReportView extends InspectionReportListItem { frozenSnapshot: Record; } export interface PendingInspectionReportItem { actId: string; visitId: string; actCode: string; actTitle: string; actYear: number; occurredAt: Date; closedAt: Date; closureSha256: string; visitCode: string; visitTitle: string; companies: ContextAsset[]; areas: ContextAsset[]; findingCount: number; } function reportNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_REPORT_NOT_FOUND', message: 'Informe de inspección no encontrado', }); } function actNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta de inspección no encontrada', }); } @Injectable() export class InspectionReportsService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly word: InspectionReportWordService, private readonly delivery: InspectionDocumentDeliveryService, ) {} async list(query: ListInspectionReportsQueryDto) { const { where, parameters, add } = this.reportFilters(query); const [countRow] = (await this.dataSource.query( `SELECT COUNT(*)::integer AS total 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 ${where}`, parameters, )) as Array<{ total: number }>; const total = Number(countRow?.total ?? 0); const limit = add(query.pageSize); const offset = add((query.page - 1) * query.pageSize); const data = (await this.dataSource.query( `${this.reportSelect(where)} ORDER BY report.report_year DESC, report.report_number DESC LIMIT ${limit} OFFSET ${offset}`, parameters, )) as InspectionReportListItem[]; return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), }, }; } async listPending(query: ListInspectionReportsQueryDto) { const conditions = [ `act.status = 'CLOSED'`, 'report.id IS NULL', 'act.closure_sha256 IS NOT NULL', ]; const parameters: unknown[] = []; const add = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; if (query.search?.trim()) { const search = add(`%${query.search.trim()}%`); conditions.push(`( 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(`act.act_year = ${add(query.year)}`); if (query.companyId) conditions.push(this.contextFilter('operator_company_id', add(query.companyId))); if (query.areaId) conditions.push(this.contextFilter('operational_area_id', add(query.areaId))); if (query.inspectorId) { const inspector = add(query.inspectorId); conditions.push(`( visit.lead_inspector_user_id = ${inspector}::uuid OR EXISTS ( SELECT 1 FROM inspection_visit_members member_filter WHERE member_filter.visit_id = visit.id AND member_filter.included = true AND member_filter.user_id = ${inspector}::uuid ) )`); } if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); const where = `WHERE ${conditions.join(' AND ')}`; const base = `FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.visit_id LEFT JOIN inspection_reports report ON report.act_id = act.id`; const [countRow] = (await this.dataSource.query( `SELECT COUNT(*)::integer AS total ${base} ${where}`, parameters, )) as Array<{ total: number }>; const total = Number(countRow?.total ?? 0); const limit = add(query.pageSize); const offset = add((query.page - 1) * query.pageSize); const data = (await this.dataSource.query(` SELECT act.id AS "actId", visit.id AS "visitId", act.code AS "actCode", act.title AS "actTitle", act.act_year AS "actYear", act.occurred_at AS "occurredAt", act.closed_at AS "closedAt", act.closure_sha256 AS "closureSha256", visit.code AS "visitCode", visit.title AS "visitTitle", COALESCE(context.companies, '[]'::jsonb) AS companies, COALESCE(context.areas, '[]'::jsonb) AS areas, COALESCE(finding_count.total, 0)::integer AS "findingCount" ${base} 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' ) finding_count ON true ${where} ORDER BY act.act_year DESC, act.act_number DESC LIMIT ${limit} OFFSET ${offset} `, parameters)) as PendingInspectionReportItem[]; return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), }, }; } 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 }; } async generate( actId: string, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); const report = await this.dataSource.transaction(async (manager) => this.ensureFrozenReport(manager, actId, principal, request)); await this.word.ensure(report.id); return this.get(report.id); } 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, 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, actId: act.id, visitId: act.visitId, actVersion: act.currentVersion, frozenSha256, pdfStatus: InspectionReportPdfStatus.PENDING, }, }, 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 }>; if (row) await this.word.ensure(row.id); await this.delivery.dispatchForAct(actId).catch(() => undefined); } private reportFilters(query: ListInspectionReportsQueryDto) { const conditions = ['1 = 1']; const parameters: unknown[] = []; const add = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; if (query.search?.trim()) { const search = add(`%${query.search.trim()}%`); conditions.push(`( report.code ILIKE ${search} OR report.title 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)}`); if (query.companyId) conditions.push(this.contextFilter('operator_company_id', add(query.companyId))); if (query.areaId) conditions.push(this.contextFilter('operational_area_id', add(query.areaId))); if (query.inspectorId) { const inspector = add(query.inspectorId); conditions.push(`( visit.lead_inspector_user_id = ${inspector}::uuid OR EXISTS ( SELECT 1 FROM inspection_visit_members member_filter WHERE member_filter.visit_id = visit.id AND member_filter.included = true AND member_filter.user_id = ${inspector}::uuid ) )`); } if (query.dateFrom) conditions.push(`act.occurred_at::date >= ${add(query.dateFrom)}::date`); if (query.dateTo) conditions.push(`act.occurred_at::date <= ${add(query.dateTo)}::date`); return { where: `WHERE ${conditions.join(' AND ')}`, parameters, add }; } private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string { return `EXISTS ( SELECT 1 FROM inspection_act_assets context_filter_link INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id WHERE context_filter_link.act_id = act.id AND context_filter_link.included = true AND context_filter_asset.${column} = ${parameter}::uuid )`; } private contextSelect(): string { 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, 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 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 `; } private reportSelect(where: string): string { return ` SELECT report.id, report.visit_id AS "visitId", report.act_id AS "actId", report.report_year AS "reportYear", report.report_number AS "reportNumber", report.code, report.status, 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 ) 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 ) AS act, JSONB_BUILD_OBJECT( '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" 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 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' ) finding_count ON true ${where} `; } private async getWithManager(manager: EntityManager, id: string): Promise { const [report] = (await manager.query( `${this.reportSelect('WHERE report.id = $1')}`, [id], )) as InspectionReportListItem[]; if (!report) throw reportNotFound(); const [snapshot] = (await manager.query( 'SELECT frozen_snapshot AS "frozenSnapshot" FROM inspection_reports WHERE id = $1', [id], )) as Array<{ frozenSnapshot: Record }>; 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 `, [visitId, userId])) as unknown[]; if (rows.length === 0) { throw new ForbiddenException({ code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED', message: 'Sólo un inspector asignado a la visita puede solicitar el informe', }); } } }