import { BadRequestException, 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, InspectionAct, InspectionActStatus, InspectionActUrgency, InspectionDeadlineBasis, InspectionDeadlineDayType, InspectionActVersionEvent, InspectionVisit, InspectionVisitStatus, } from '../database/entities'; import type { CancelInspectionActDto } from './dto/cancel-inspection-act.dto'; import type { CreateInspectionActDto } from './dto/create-inspection-act.dto'; import type { ListInspectionActsQueryDto } from './dto/list-inspection-acts-query.dto'; import type { UpdateInspectionActDto } from './dto/update-inspection-act.dto'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; interface ActPerson { id: string; username: string; firstName: string; lastName: string; } interface ActAsset { id: string; code: string; name: string; typeName: string; } interface ActVisitSummary { id: string; code: string; status: InspectionVisitStatus; actualStartedAt: Date | null; } interface ActContextAsset { id: string; code: string; name: string; } interface ActReportSummary { id: string; code: string; status: string; pdfStatus: string; wordStatus: string; gedoIfIdentifier: string | null; gedoOfficializedAt: Date | null; generatedAt: Date; } interface ActTerritorialContext { department: ActContextAsset | null; area: ActContextAsset | null; yacimiento: ActContextAsset | null; company: ActContextAsset | null; installations: ActContextAsset[]; subinstallations: ActContextAsset[]; legacyAreaScope: boolean; } export interface InspectionActListItem { id: string; visitId: string; visit: ActVisitSummary; actYear: number; actNumber: number; code: string; status: InspectionActStatus; occurredAt: Date; title: string; summary: string; observations: string | null; urgency: InspectionActUrgency | null; deadlineDays: number | null; deadlineDayType: InspectionDeadlineDayType | null; deadlineBasis: InspectionDeadlineBasis | null; deadlineBaseAt: Date | null; deadlineAt: Date | null; lockedAt: Date | null; lockedBy: string | null; lockedSha256: string | null; sealedAt: Date | null; sealedBy: string | null; currentVersion: number; cancellationReason: string | null; closedAt: Date | null; closedBy: string | null; closureSha256: string | null; assetCount: number; findingCount: number; companies: ActContextAsset[]; areas: ActContextAsset[]; context: ActTerritorialContext; report: ActReportSummary | null; createdBy: ActPerson | null; updatedBy: ActPerson | null; createdAt: Date; updatedAt: Date; } interface InspectionActVersionView { id: string; versionNumber: number; event: InspectionActVersionEvent; snapshot: Record; actorUserId: string | null; actorUsername: string | null; createdAt: Date; } export interface InspectionActView extends InspectionActListItem { assets: ActAsset[]; versions: InspectionActVersionView[]; } function actNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_ACT_NOT_FOUND', message: 'Acta de inspección no encontrada', }); } function visitNotFound(): NotFoundException { return new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Visita de inspección no encontrada', }); } @Injectable() export class InspectionActsService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, ) {} async listGlobal(query: ListInspectionActsQueryDto) { 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(`( act.code ILIKE ${search} OR act.title ILIKE ${search} OR visit.code 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 WHERE search_link.act_id = act.id AND search_link.included = true AND (search_asset.code ILIKE ${search} OR search_asset.name ILIKE ${search}) ) OR EXISTS ( SELECT 1 FROM assets search_company WHERE search_company.id=visit.operator_company_id AND search_company.name ILIKE ${search} ) OR EXISTS ( SELECT 1 FROM assets search_area WHERE search_area.id=visit.operational_area_id AND search_area.name ILIKE ${search} ) )`); } if (query.status) conditions.push(`act.status = ${add(query.status)}`); if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`); if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`); 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 [countRow] = (await this.dataSource.query( `SELECT COUNT(*)::integer AS total FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.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.actSelect(where)} ORDER BY act.act_year DESC, act.act_number DESC LIMIT ${limit} OFFSET ${offset}`, parameters, )) as InspectionActListItem[]; return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), }, }; } async listForVisit(visitId: string, query: ListInspectionActsQueryDto) { await this.requireVisit(this.dataSource.manager, visitId); const conditions = ['act.visit_id = $1']; const parameters: unknown[] = [visitId]; 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})`); } if (query.status) conditions.push(`act.status = ${add(query.status)}`); if (query.year) conditions.push(`act.act_year = ${add(query.year)}`); if (query.companyId) conditions.push(`visit.operator_company_id = ${add(query.companyId)}::uuid`); if (query.areaId) conditions.push(`visit.operational_area_id = ${add(query.areaId)}::uuid`); 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 [countRow] = (await this.dataSource.query( `SELECT COUNT(*)::integer AS total FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.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.actSelect(where)} ORDER BY act.act_year DESC, act.act_number DESC LIMIT ${limit} OFFSET ${offset}`, parameters, )) as InspectionActListItem[]; return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), }, }; } async getById(id: string): Promise { return this.dataSource.transaction((manager) => this.loadView(manager, id)); } async listFieldMedia(id: string) { const [act] = await this.dataSource.query( 'SELECT id FROM inspection_acts WHERE id=$1::uuid', [id], ) as Array<{ id: string }>; if (!act) throw actNotFound(); const data = await this.dataSource.query(` SELECT media.id, media.asset_id AS "assetId", asset.code AS "assetCode", asset.name AS "assetName", media.kind, media.original_name AS "originalName", media.mime_type AS "mimeType", media.size_bytes::double precision AS "sizeBytes", media.sha256, media.title, media.description, media.captured_at AS "capturedAt", media.latitude::double precision AS latitude, media.longitude::double precision AS longitude, media.accuracy_m::double precision AS "accuracyM", media.source, capture.device_captured_at AS "fieldCapturedAt", capture.created_at AS "createdAt" FROM inspection_acts act INNER JOIN inspection_act_assets link ON link.act_id=act.id AND link.included=true INNER JOIN asset_field_capture_events capture ON capture.visit_id=act.visit_id AND capture.asset_id=link.asset_id AND capture.event_type='PHOTO' INNER JOIN asset_media media ON media.id=capture.media_id AND media.deleted_at IS NULL INNER JOIN assets asset ON asset.id=media.asset_id WHERE act.id=$1::uuid ORDER BY capture.device_captured_at DESC,capture.created_at DESC,media.id `, [id]); return { data }; } async create( visitId: string, dto: CreateInspectionActDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const visit = await this.lockVisit(manager, visitId); this.assertVisitOpen(visit); await this.assertActorAssigned(manager, visitId, principal); if (dto.clientGeneratedId) { const [existing] = await manager.query(` SELECT id, visit_id AS "visitId" FROM inspection_acts WHERE id=$1 `, [dto.clientGeneratedId]) as Array<{ id: string; visitId: string }>; if (existing) { if (existing.visitId !== visitId) { throw new ConflictException({ code: 'INSPECTION_ACT_CLIENT_ID_REUSED', message: 'El identificador offline del Acta ya fue utilizado en otra inspección', }); } return this.loadView(manager, existing.id); } } await this.assertVisitHasNoDraftAct(manager, visitId); await this.assertVisitAssets(manager, visitId, dto.assetIds); const occurredAt = new Date(dto.occurredAt); const actYear = await this.yearAtProjectTimezone(manager, occurredAt); const actNumber = await this.allocateNumber(manager, actYear); if (actNumber > 99999) { throw new ConflictException({ code: 'INSPECTION_ACT_SEQUENCE_EXHAUSTED', message: 'La numeración anual de actas agotó su rango disponible', }); } const [dateRow] = (await manager.query(` SELECT TO_CHAR($1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza', 'DD-MM-YY') AS date_part `, [occurredAt])) as Array<{ date_part: string }>; const code = `ACT-${String(actNumber).padStart(5, '0')}-${dateRow.date_part}`; const act = manager.getRepository(InspectionAct).create({ id: dto.clientGeneratedId, visitId, actYear, actNumber, code, status: InspectionActStatus.DRAFT, occurredAt, title: dto.title, summary: dto.summary, observations: dto.observations ?? null, urgency: null, deadlineDays: null, deadlineDayType: null, deadlineBasis: null, deadlineBaseAt: null, deadlineAt: null, lockedAt: null, lockedBy: null, lockedSha256: null, sealedAt: null, sealedBy: null, currentVersion: 0, cancellationReason: null, createdBy: principal.userId, updatedBy: principal.userId, }); await manager.getRepository(InspectionAct).save(act); await this.replaceAssetLinks(manager, act.id, dto.assetIds, principal.userId); const versionNumber = await this.captureVersion( manager, act, InspectionActVersionEvent.CREATED, principal, ); const created = await this.loadView(manager, act.id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_CREATED, entityType: 'inspection_act', entityId: act.id, afterData: this.auditView(created), metadata: { visitId, versionNumber }, }, manager); return created; }); } async update( id: string, dto: UpdateInspectionActDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); if (Object.keys(dto).length === 0) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); } return this.dataSource.transaction(async (manager) => { const act = await this.lockAct(manager, id); const visit = await this.lockVisit(manager, act.visitId); this.assertVisitOpen(visit); this.assertActDraft(act); await this.assertActorAssigned(manager, visit.id, principal); const nextOccurredAt = dto.occurredAt ? new Date(dto.occurredAt) : act.occurredAt; const nextYear = await this.yearAtProjectTimezone(manager, nextOccurredAt); if (nextYear !== act.actYear) { throw new ConflictException({ code: 'INSPECTION_ACT_YEAR_LOCKED', message: 'La fecha no puede cambiar el año de la numeración oficial asignada', }); } const nextAssetIds = dto.assetIds ?? await this.activeAssetIds(manager, id); await this.assertVisitAssets(manager, visit.id, nextAssetIds); const before = await this.loadView(manager, id); if (dto.occurredAt !== undefined) act.occurredAt = nextOccurredAt; if (dto.title !== undefined) act.title = dto.title; if (dto.summary !== undefined) act.summary = dto.summary; if (dto.observations !== undefined) act.observations = dto.observations; act.updatedBy = principal.userId; await manager.getRepository(InspectionAct).save(act); if (dto.assetIds !== undefined) { await this.replaceAssetLinks(manager, id, dto.assetIds, principal.userId); } const versionNumber = await this.captureVersion( manager, act, InspectionActVersionEvent.UPDATED, principal, ); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_UPDATED, entityType: 'inspection_act', entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), metadata: { visitId: visit.id, versionNumber }, }, manager); return updated; }); } async cancel( id: string, dto: CancelInspectionActDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { assertMobileInspector(principal); return this.dataSource.transaction(async (manager) => { const act = await this.lockAct(manager, id); const visit = await this.lockVisit(manager, act.visitId); if (visit.status === InspectionVisitStatus.CLOSED) { throw new ConflictException({ code: 'INSPECTION_VISIT_CLOSED', message: 'No se puede cancelar un acta de una visita cerrada', }); } this.assertActDraft(act); const before = await this.loadView(manager, id); act.status = InspectionActStatus.CANCELLED; act.cancellationReason = dto.reason; act.updatedBy = principal.userId; await manager.getRepository(InspectionAct).save(act); const versionNumber = await this.captureVersion( manager, act, InspectionActVersionEvent.CANCELLED, principal, ); const cancelled = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.INSPECTION_ACT_CANCELLED, entityType: 'inspection_act', entityId: id, beforeData: this.auditView(before), afterData: this.auditView(cancelled), metadata: { visitId: visit.id, versionNumber }, }, manager); return cancelled; }); } private actSelect(where: string): string { return ` SELECT act.id, act.visit_id AS "visitId", JSONB_BUILD_OBJECT( 'id', visit.id, 'code', visit.code, 'status', visit.status, 'actualStartedAt', visit.actual_started_at ) AS visit, act.act_year AS "actYear", act.act_number AS "actNumber", act.code, act.status, act.occurred_at AS "occurredAt", act.title, act.summary, act.observations, act.urgency, act.deadline_days AS "deadlineDays", act.deadline_day_type AS "deadlineDayType", act.deadline_basis AS "deadlineBasis", act.deadline_base_at AS "deadlineBaseAt", act.deadline_at AS "deadlineAt", act.locked_at AS "lockedAt", act.locked_by AS "lockedBy", act.locked_sha256 AS "lockedSha256", act.sealed_at AS "sealedAt", act.sealed_by AS "sealedBy", act.current_version AS "currentVersion", act.cancellation_reason AS "cancellationReason", act.closed_at AS "closedAt", act.closed_by AS "closedBy", act.closure_sha256 AS "closureSha256", COALESCE(asset_count.total, 0)::integer AS "assetCount", COALESCE(finding_count.total, 0)::integer AS "findingCount", COALESCE(context.companies, '[]'::jsonb) AS companies, COALESCE(context.areas, '[]'::jsonb) AS areas, JSONB_BUILD_OBJECT( 'department', context.department, 'area', context.area, 'yacimiento', context.yacimiento, 'company', context.company, 'installations', COALESCE(context.installations, '[]'::jsonb), 'subinstallations', COALESCE(context.subinstallations, '[]'::jsonb), 'legacyAreaScope', context.legacy_area_scope ) AS context, CASE WHEN report.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', report.id, 'code', report.code, 'status', report.status, 'pdfStatus', report.pdf_status, 'wordStatus', report.word_status, 'gedoIfIdentifier', report.gedo_if_identifier, 'gedoOfficializedAt', report.gedo_officialized_at, 'generatedAt', report.generated_at ) END AS report, CASE WHEN creator.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', creator.id, 'username', creator.username, 'firstName', creator.first_name, 'lastName', creator.last_name ) END AS "createdBy", CASE WHEN updater.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', updater.id, 'username', updater.username, 'firstName', updater.first_name, 'lastName', updater.last_name ) END AS "updatedBy", act.created_at AS "createdAt", act.updated_at AS "updatedAt" FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.visit_id LEFT JOIN users creator ON creator.id = act.created_by LEFT JOIN users updater ON updater.id = act.updated_by LEFT JOIN inspection_reports report ON report.act_id = act.id LEFT JOIN LATERAL ( 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, CASE WHEN department.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id',department.id,'code',department.code,'name',department.name ) END AS department, CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id',area.id,'code',area.code,'name',area.name ) END AS area, CASE WHEN yacimiento.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id',yacimiento.id,'code',yacimiento.code,'name',yacimiento.name ) END AS yacimiento, CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id',company.id,'code',company.code,'name',company.name ) END AS company, COALESCE(affected.installations, '[]'::jsonb) AS installations, COALESCE(affected.subinstallations, '[]'::jsonb) AS subinstallations, (lower(COALESCE(scope_type.code,''))='area') AS legacy_area_scope 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 LEFT JOIN assets department ON department.id=area.parent_id LEFT JOIN assets scope_asset ON scope_asset.id=context_visit.scope_asset_id LEFT JOIN asset_types scope_type ON scope_type.id=scope_asset.asset_type_id LEFT JOIN assets yacimiento ON yacimiento.id=CASE WHEN lower(COALESCE(scope_type.code,''))='yacimiento' THEN scope_asset.id ELSE NULL END LEFT JOIN LATERAL ( SELECT COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code) FROM ( SELECT DISTINCT installation.id, installation.code, installation.name FROM inspection_findings finding_context INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id LEFT JOIN assets installation ON installation.id=CASE WHEN lower(finding_type.code)='instalacion' THEN finding_asset.id WHEN lower(finding_type.code)='subinstalacion' THEN finding_asset.parent_id ELSE NULL END WHERE finding_context.act_id=act.id AND finding_context.status<>'VOIDED' AND installation.id IS NOT NULL ) x ), '[]'::jsonb) AS installations, COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT('id',x.id,'code',x.code,'name',x.name) ORDER BY x.name,x.code) FROM ( SELECT DISTINCT finding_asset.id, finding_asset.code, finding_asset.name FROM inspection_findings finding_context INNER JOIN assets finding_asset ON finding_asset.id=finding_context.asset_id INNER JOIN asset_types finding_type ON finding_type.id=finding_asset.asset_type_id WHERE finding_context.act_id=act.id AND finding_context.status<>'VOIDED' AND lower(finding_type.code)='subinstalacion' ) x ), '[]'::jsonb) AS subinstallations ) affected ON true WHERE context_visit.id=act.visit_id ) context ON true LEFT JOIN LATERAL ( SELECT COUNT(*) AS total FROM inspection_act_assets link WHERE link.act_id = act.id AND link.included = true ) asset_count 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 loadView(manager: EntityManager, id: string): Promise { const [act] = (await manager.query( this.actSelect('WHERE act.id = $1'), [id], )) as InspectionActListItem[]; if (!act) throw actNotFound(); const assets = (await manager.query(` SELECT asset.id, asset.code, asset.name, asset_type.name AS "typeName" FROM inspection_act_assets link INNER JOIN assets asset ON asset.id = link.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE link.act_id = $1 AND link.included = true ORDER BY asset.code, asset.name `, [id])) as ActAsset[]; const versions = (await manager.query(` SELECT version.id, version.version_number AS "versionNumber", version.event, version.snapshot, version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername", version.created_at AS "createdAt" FROM inspection_act_versions version WHERE version.act_id = $1 ORDER BY version.version_number DESC `, [id])) as InspectionActVersionView[]; return { ...act, assets, versions }; } private async requireVisit(manager: EntityManager, id: string): Promise { const rows = await manager.query('SELECT 1 FROM inspection_visits WHERE id = $1', [id]) as unknown[]; if (rows.length === 0) throw visitNotFound(); } private async lockVisit(manager: EntityManager, id: string): Promise { const visit = await manager.getRepository(InspectionVisit) .createQueryBuilder('visit') .where('visit.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!visit) throw visitNotFound(); return visit; } private async lockAct(manager: EntityManager, id: string): Promise { const act = await manager.getRepository(InspectionAct) .createQueryBuilder('act') .where('act.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!act) throw actNotFound(); return act; } private assertVisitOpen(visit: InspectionVisit): void { if (visit.status !== InspectionVisitStatus.IN_PROGRESS) { throw new ConflictException({ code: 'INSPECTION_VISIT_NOT_IN_PROGRESS', message: 'La visita debe estar en curso para crear o editar actas', }); } } private assertActDraft(act: InspectionAct): void { if (act.status !== InspectionActStatus.DRAFT) { throw new ConflictException({ code: 'INSPECTION_ACT_IMMUTABLE', message: 'Sólo se puede editar o cancelar un acta en borrador', }); } } private async assertActorAssigned( manager: EntityManager, visitId: string, principal: AuthPrincipal, ): Promise { if (principal.permissions.includes('inspections.manage')) return; const rows = await manager.query(` SELECT 1 FROM inspection_visit_members WHERE visit_id = $1 AND user_id = $2 AND included = true `, [visitId, principal.userId]) as unknown[]; if (rows.length === 0) { throw new ForbiddenException({ code: 'INSPECTION_VISIT_NOT_ASSIGNED', message: 'La visita no está asignada al usuario actual', }); } } private async assertVisitHasNoDraftAct( manager: EntityManager, visitId: string, ): Promise { const rows = (await manager.query(` SELECT code FROM inspection_acts WHERE visit_id = $1 AND status = 'DRAFT' LIMIT 1 `, [visitId])) as Array<{ code: string }>; if (rows.length > 0) { throw new ConflictException({ code: 'INSPECTION_VISIT_DRAFT_ACT_ALREADY_EXISTS', message: 'La inspección ya tiene un acta en borrador; finalizala o cancelala antes de crear otra', }); } } private async assertVisitAssets( manager: EntityManager, visitId: string, assetIds: string[], ): Promise { if (assetIds.length === 0) return; const [row] = (await manager.query(` SELECT COUNT(*)::integer AS count FROM inspection_visit_assets WHERE visit_id = $1 AND asset_id = ANY($2::uuid[]) AND included = true `, [visitId, assetIds])) as Array<{ count: number }>; if (Number(row?.count ?? 0) !== assetIds.length) { throw new BadRequestException({ code: 'INSPECTION_ACT_ASSET_INVALID', message: 'Cada inventario del acta debe estar incluido en la inspección', }); } } private async yearAtProjectTimezone(manager: EntityManager, value: Date): Promise { const [row] = (await manager.query(` SELECT EXTRACT(YEAR FROM $1::timestamptz AT TIME ZONE 'America/Argentina/Mendoza')::integer AS year `, [value])) as Array<{ year: number }>; return Number(row.year); } 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.ACT, year])) as Array<{ number: number }>; return Number(row.number); } private async activeAssetIds(manager: EntityManager, actId: string): Promise { const rows = (await manager.query(` SELECT asset_id AS id FROM inspection_act_assets WHERE act_id = $1 AND included = true ORDER BY created_at, asset_id `, [actId])) as Array<{ id: string }>; return rows.map((row) => row.id); } private async replaceAssetLinks( manager: EntityManager, actId: string, assetIds: string[], userId: string, ): Promise { await manager.query(` UPDATE inspection_act_assets SET included = false, updated_at = CURRENT_TIMESTAMP WHERE act_id = $1 AND included = true `, [actId]); for (const assetId of assetIds) { await manager.query(` INSERT INTO inspection_act_assets (act_id, asset_id, included, added_by) VALUES ($1, $2, true, $3) ON CONFLICT (act_id, asset_id) DO UPDATE SET included = true, added_by = EXCLUDED.added_by, updated_at = CURRENT_TIMESTAMP `, [actId, assetId, userId]); } } private async captureVersion( manager: EntityManager, act: InspectionAct, event: InspectionActVersionEvent, principal: AuthPrincipal, ): Promise { const [row] = (await manager.query(` WITH updated AS ( UPDATE inspection_acts SET current_version = current_version + 1 WHERE id = $1 RETURNING current_version ) SELECT current_version AS "versionNumber" FROM updated `, [act.id])) as Array<{ versionNumber: number }>; const versionNumber = Number(row.versionNumber); act.currentVersion = versionNumber; const snapshot = await this.buildSnapshot(manager, act.id); await manager.query(` INSERT INTO inspection_act_versions ( act_id, version_number, event, snapshot, actor_user_id, actor_username ) VALUES ($1, $2, $3, $4, $5, $6) `, [act.id, versionNumber, event, snapshot, principal.userId, principal.username]); return versionNumber; } private async buildSnapshot( manager: EntityManager, actId: string, ): Promise> { const [row] = (await manager.query(` SELECT JSONB_BUILD_OBJECT( 'id', act.id, 'code', act.code, 'actYear', act.act_year, 'actNumber', act.act_number, 'status', act.status, 'occurredAt', act.occurred_at, 'title', act.title, 'summary', act.summary, 'observations', act.observations, 'urgency', act.urgency, 'deadlineDays', act.deadline_days, 'deadlineDayType', act.deadline_day_type, 'deadlineBasis', act.deadline_basis, 'deadlineBaseAt', act.deadline_base_at, 'deadlineAt', act.deadline_at, 'lockedAt', act.locked_at, 'lockedSha256', act.locked_sha256, 'sealedAt', act.sealed_at, 'currentVersion', act.current_version, 'cancellationReason', act.cancellation_reason, 'createdBy', act.created_by, 'updatedBy', act.updated_by, 'visit', JSONB_BUILD_OBJECT( 'id', visit.id, 'code', visit.code, 'status', visit.status, 'scopeAssetId', visit.scope_asset_id, 'operationalAreaId', visit.operational_area_id, 'operatorCompanyId', visit.operator_company_id, 'actualStartedAt', visit.actual_started_at ), 'assets', COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'id', asset.id, 'code', asset.code, 'name', asset.name, 'typeId', asset.asset_type_id, 'typeName', asset_type.name, 'currentVersion', asset.current_version ) ORDER BY asset.code, asset.id) FROM inspection_act_assets link INNER JOIN assets asset ON asset.id = link.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id WHERE link.act_id = act.id AND link.included = true ), '[]'::jsonb) ) AS snapshot FROM inspection_acts act INNER JOIN inspection_visits visit ON visit.id = act.visit_id WHERE act.id = $1 `, [actId])) as Array<{ snapshot: Record }>; if (!row) throw actNotFound(); return row.snapshot; } private auditView(act: InspectionActView): Record { return { visitId: act.visitId, code: act.code, actYear: act.actYear, actNumber: act.actNumber, status: act.status, occurredAt: act.occurredAt, title: act.title, summary: act.summary, observations: act.observations, urgency: act.urgency, deadlineDays: act.deadlineDays, deadlineDayType: act.deadlineDayType, deadlineBasis: act.deadlineBasis, deadlineBaseAt: act.deadlineBaseAt, deadlineAt: act.deadlineAt, lockedAt: act.lockedAt, lockedSha256: act.lockedSha256, sealedAt: act.sealedAt, currentVersion: act.currentVersion, cancellationReason: act.cancellationReason, closedAt: act.closedAt, closedBy: act.closedBy, closureSha256: act.closureSha256, assetIds: act.assets.map((asset) => asset.id), }; } }