510 lines
18 KiB
TypeScript
510 lines
18 KiB
TypeScript
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,
|
|
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-INF-V4';
|
|
|
|
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;
|
|
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;
|
|
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;
|
|
sealedAt: Date | null;
|
|
urgency: string;
|
|
deadlineAt: Date | null;
|
|
};
|
|
visit: { id: string; code: string; status: string };
|
|
companies: ContextAsset[];
|
|
areas: ContextAsset[];
|
|
findingCount: number;
|
|
}
|
|
|
|
export interface InspectionReportView extends InspectionReportListItem {
|
|
frozenSnapshot: Record<string, unknown>;
|
|
}
|
|
|
|
export interface PendingInspectionReportItem {
|
|
actId: string;
|
|
visitId: string;
|
|
actCode: string;
|
|
actTitle: string;
|
|
actYear: number;
|
|
occurredAt: Date;
|
|
sealedAt: Date;
|
|
closureSha256: string;
|
|
visitCode: 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 = 'SEALED'`,
|
|
'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}
|
|
)`);
|
|
}
|
|
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.sealed_at AS "sealedAt",
|
|
act.closure_sha256 AS "closureSha256",
|
|
visit.code AS "visitCode",
|
|
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<InspectionReportView> {
|
|
return this.getWithManager(this.dataSource.manager, id);
|
|
}
|
|
|
|
async generate(
|
|
actId: string,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
): Promise<InspectionReportView> {
|
|
assertMobileInspector(principal);
|
|
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<InspectionReportView> {
|
|
const [act] = (await manager.query(`
|
|
SELECT
|
|
act.id,
|
|
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"
|
|
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;
|
|
}>;
|
|
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<string, unknown> | 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,
|
|
actId: act.id,
|
|
inspectionId: act.visitId,
|
|
actVersion: act.currentVersion,
|
|
frozenSha256,
|
|
status: InspectionReportStatus.WORKING,
|
|
},
|
|
}, manager);
|
|
return this.getWithManager(manager, created.id);
|
|
}
|
|
|
|
async ensureWordForAct(actId: string): Promise<void> {
|
|
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 report.gedo_if_identifier ILIKE ${search}
|
|
OR act.code ILIKE ${search}
|
|
OR act.title ILIKE ${search}
|
|
OR visit.code 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 {
|
|
// Reports are historical documents. Their Area/Company filters must use the
|
|
// parent Inspection snapshot, never the mutable/current Inventory context.
|
|
return `visit.${column} = ${parameter}::uuid`;
|
|
}
|
|
|
|
private contextSelect(): string {
|
|
return `
|
|
SELECT
|
|
CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
|
JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
|
|
) END AS companies,
|
|
CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY(
|
|
JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name)
|
|
) END AS areas
|
|
FROM inspection_visits context_visit
|
|
LEFT JOIN assets company ON company.id=context_visit.operator_company_id
|
|
LEFT JOIN assets area ON area.id=context_visit.operational_area_id
|
|
WHERE context_visit.id=act.visit_id
|
|
`;
|
|
}
|
|
|
|
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.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.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,'sealedAt',act.sealed_at,
|
|
'urgency',act.urgency,'deadlineAt',act.deadline_at
|
|
) AS act,
|
|
JSONB_BUILD_OBJECT(
|
|
'id',visit.id,'code',visit.code,'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<InspectionReportView> {
|
|
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<string, unknown> }>;
|
|
return { ...report, frozenSnapshot: snapshot.frozenSnapshot };
|
|
}
|
|
|
|
private async assertActorAssigned(manager: EntityManager, visitId: string, userId: string): Promise<void> {
|
|
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) {
|
|
throw new ForbiddenException({
|
|
code: 'INSPECTION_REPORT_ACTOR_NOT_ASSIGNED',
|
|
message: 'Sólo un inspector asignado a la inspección puede solicitar el informe',
|
|
});
|
|
}
|
|
}
|
|
}
|