import { BadRequestException, ConflictException, Injectable, NotFoundException, } from '@nestjs/common'; import { DataSource, EntityManager } from 'typeorm'; import { AuditService } from '../audit/audit.service'; import type { AuthPrincipal, RequestWithContext, } from '../common/http/request-context'; import { Asset, AssetDataOrigin, AssetAttributeDefinition, AssetAttributeValue, AssetInformationStatus, AssetOperationalStatus, AssetType, OrganizationKind, OrganizationProfile, AssetTypeOperationalRole, AssetVersionChangeType, AuditAction, } from '../database/entities'; import { administrationAuditContext, isUniqueViolation, } from '../administration/common/administration-audit'; import { validateAssetAttributeValues } from './asset-attribute-validator'; import type { CreateAssetDto } from './dto/create-asset.dto'; import type { UpdateAssetDto } from './dto/update-asset.dto'; import type { ChangeAssetStatusDto } from './dto/change-asset-status.dto'; import type { ChangeAssetOperationalStatusDto } from './dto/change-asset-operational-status.dto'; import type { ListAssetsQueryDto } from './dto/list-assets-query.dto'; import type { ListAssetTreeQueryDto } from './dto/list-asset-tree-query.dto'; import type { ListAssetTreeChildrenQueryDto } from './dto/list-asset-tree-children-query.dto'; import type { ParentOptionsQueryDto } from './dto/parent-options-query.dto'; import { AssetHistoryService } from './asset-history.service'; import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import type { CreateFieldDiscoveryDto } from './dto/create-field-discovery.dto'; import type { ListFieldDiscoveriesQueryDto } from './dto/list-field-discoveries-query.dto'; import type { MatchFieldDiscoveryDto, RejectFieldDiscoveryDto, ReviewFieldDiscoveryDto } from './dto/review-field-discovery.dto'; import type { ChangeAssetContextDto } from './dto/change-asset-context.dto'; import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service'; export interface AssetListItem { id: string; code: string; name: string; commonName: string | null; type: { id: string; code: string; name: string }; parent: { id: string; code: string; name: string } | null; operationalArea: { id: string; code: string; name: string } | null; operatorCompany: { id: string; code: string; name: string } | null; informationStatus: AssetInformationStatus; operationalStatus: AssetOperationalStatus; childrenCount: number; hasGeometry: boolean; geometryType: string | null; mediaCount: number; dataOrigin: AssetDataOrigin; provenanceVerified: boolean; currentVersion: number; updatedAt: Date; } export interface AssetView extends AssetListItem { description: string | null; createdAt: Date; createdBy: string | null; updatedBy: string | null; attributes: Array<{ definitionId: string; code: string; name: string; dataType: string; isRequired: boolean; unit: string | null; options: string[] | null; sortOrder: number; value: unknown; }>; } function assetNotFound(): NotFoundException { return new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Activo no encontrado', }); } @Injectable() export class AssetsService { constructor( private readonly dataSource: DataSource, private readonly audit: AuditService, private readonly history: AssetHistoryService, private readonly fieldDiscoveryInspectionLinks: FieldDiscoveryInspectionLinkService, ) {} async list(query: ListAssetsQueryDto) { const conditions: string[] = []; const parameters: unknown[] = []; const addParameter = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; if (query.search?.trim()) { const placeholder = addParameter(`%${query.search.trim()}%`); conditions.push(`(asset.code ILIKE ${placeholder} OR asset.name ILIKE ${placeholder} OR asset.common_name ILIKE ${placeholder} OR asset.description ILIKE ${placeholder} OR EXISTS (SELECT 1 FROM asset_external_identifiers external_id WHERE external_id.asset_id = asset.id AND external_id.valid_until IS NULL AND (external_id.value ILIKE ${placeholder} OR external_id.namespace ILIKE ${placeholder})) OR EXISTS (SELECT 1 FROM asset_attribute_values attribute_value JOIN asset_attribute_definitions attribute_definition ON attribute_definition.id=attribute_value.definition_id WHERE attribute_value.asset_id=asset.id AND attribute_definition.is_active=true AND attribute_definition.code IN ('fabricante','modelo','numero_serie','identificacion_oficial','ubicacion','ubicacion_fuente') AND (attribute_value.value #>> '{}') ILIKE ${placeholder}) OR EXISTS (SELECT 1 FROM asset_source_documents source_link JOIN source_documents source_document ON source_document.id=source_link.document_id WHERE source_link.asset_id=asset.id AND (source_document.title ILIKE ${placeholder} OR source_document.document_number ILIKE ${placeholder})))`); } if (query.typeId) conditions.push(`asset.asset_type_id = ${addParameter(query.typeId)}`); if (query.status) conditions.push(`asset.information_status = ${addParameter(query.status)}`); if (query.operationalStatus) conditions.push(`asset.operational_status = ${addParameter(query.operationalStatus)}`); if (query.needsValidation === true) conditions.push(`asset.information_status NOT IN ('VALIDATED','INACTIVE')`); if (query.needsValidation === false) conditions.push(`asset.information_status = 'VALIDATED'`); if (query.hasGeometry === true) conditions.push(`EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id = asset.id)`); if (query.hasGeometry === false) conditions.push(`NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id = asset.id)`); if (query.parentId) conditions.push(`asset.parent_id = ${addParameter(query.parentId)}`); if (query.operationalAreaId) conditions.push(`asset.operational_area_id = ${addParameter(query.operationalAreaId)}`); if (query.operatorCompanyId) conditions.push(`asset.operator_company_id = ${addParameter(query.operatorCompanyId)}`); const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const [countRow] = (await this.dataSource.query( `SELECT COUNT(*)::integer AS total FROM assets asset ${where}`, parameters, )) as Array<{ total: number }>; const total = Number(countRow?.total ?? 0); const offset = (query.page - 1) * query.pageSize; const limitParameter = addParameter(query.pageSize); const offsetParameter = addParameter(offset); const data = (await this.dataSource.query( `${this.listQuery(where)} LIMIT ${limitParameter} OFFSET ${offsetParameter}`, parameters, )) as AssetListItem[]; return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), }, }; } async tree(query: ListAssetTreeQueryDto): Promise<{ data: AssetListItem[]; meta: { count: number; truncated: boolean } }> { const conditions: string[] = []; const parameters: unknown[] = []; const addParameter = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; if (query.search?.trim()) { const p = addParameter(`%${query.search.trim()}%`); conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR asset.common_name ILIKE ${p} OR asset.description ILIKE ${p} OR EXISTS (SELECT 1 FROM asset_external_identifiers external_id WHERE external_id.asset_id = asset.id AND external_id.valid_until IS NULL AND (external_id.value ILIKE ${p} OR external_id.namespace ILIKE ${p})) OR EXISTS (SELECT 1 FROM asset_attribute_values attribute_value JOIN asset_attribute_definitions attribute_definition ON attribute_definition.id=attribute_value.definition_id WHERE attribute_value.asset_id=asset.id AND attribute_definition.is_active=true AND attribute_definition.code IN ('fabricante','modelo','numero_serie','identificacion_oficial','ubicacion','ubicacion_fuente') AND (attribute_value.value #>> '{}') ILIKE ${p}))`); } if (query.typeId) conditions.push(`asset.asset_type_id = ${addParameter(query.typeId)}`); if (query.status) conditions.push(`asset.information_status = ${addParameter(query.status)}`); if (query.operationalStatus) conditions.push(`asset.operational_status = ${addParameter(query.operationalStatus)}`); if (query.operationalAreaId) conditions.push(`asset.operational_area_id = ${addParameter(query.operationalAreaId)}`); if (query.operatorCompanyId) conditions.push(`asset.operator_company_id = ${addParameter(query.operatorCompanyId)}`); if (query.needsValidation === true) conditions.push(`asset.information_status NOT IN ('VALIDATED','INACTIVE')`); if (query.needsValidation === false) conditions.push(`asset.information_status = 'VALIDATED'`); if (query.hasGeometry === true) conditions.push(`EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id=asset.id)`); if (query.hasGeometry === false) conditions.push(`NOT EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id=asset.id)`); const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const limit = 5001; parameters.push(limit); const limitParam=`$${parameters.length}`; const rows = (await this.dataSource.query(` WITH RECURSIVE matched AS ( SELECT asset.id FROM assets asset ${where} ), visible(id) AS ( SELECT id FROM matched UNION SELECT parent.id FROM assets child JOIN visible current ON current.id=child.id JOIN assets parent ON parent.id=child.parent_id ) ${this.listQuery('WHERE asset.id IN (SELECT id FROM visible)').replace(/ORDER BY asset\.name ASC, asset\.code ASC\s*$/, '')} ORDER BY COALESCE(parent.name, asset.name), asset.name, asset.code LIMIT ${limitParam} `, parameters)) as AssetListItem[]; const truncated = rows.length > 5000; return { data: truncated ? rows.slice(0, 5000) : rows, meta: { count: truncated ? 5000 : rows.length, truncated } }; } async treeChildren(query: ListAssetTreeChildrenQueryDto): Promise<{ data: AssetListItem[]; meta: { count: number; hasMore: boolean; parentId: string | null } }> { const parameters: unknown[] = []; const conditions: string[] = []; const addParameter = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; if (query.search?.trim()) { const p = addParameter(`%${query.search.trim()}%`); conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR asset.common_name ILIKE ${p} OR asset.description ILIKE ${p} OR EXISTS (SELECT 1 FROM asset_external_identifiers external_id WHERE external_id.asset_id=asset.id AND external_id.valid_until IS NULL AND (external_id.value ILIKE ${p} OR external_id.namespace ILIKE ${p})) OR EXISTS (SELECT 1 FROM asset_attribute_values attribute_value JOIN asset_attribute_definitions attribute_definition ON attribute_definition.id=attribute_value.definition_id WHERE attribute_value.asset_id=asset.id AND attribute_definition.is_active=true AND attribute_definition.code IN ('fabricante','modelo','numero_serie','identificacion_oficial','ubicacion','ubicacion_fuente') AND (attribute_value.value #>> '{}') ILIKE ${p}))`); } if (query.typeId) conditions.push(`asset.asset_type_id = ${addParameter(query.typeId)}`); if (query.status) conditions.push(`asset.information_status = ${addParameter(query.status)}`); if (query.operationalStatus) conditions.push(`asset.operational_status = ${addParameter(query.operationalStatus)}`); if (query.operationalAreaId) conditions.push(`asset.operational_area_id = ${addParameter(query.operationalAreaId)}`); if (query.operatorCompanyId) conditions.push(`asset.operator_company_id = ${addParameter(query.operatorCompanyId)}`); if (query.needsValidation === true) conditions.push(`asset.information_status NOT IN ('VALIDATED','INACTIVE')`); if (query.needsValidation === false) conditions.push(`asset.information_status = 'VALIDATED'`); if (query.hasGeometry === true) conditions.push(`EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id=asset.id)`); if (query.hasGeometry === false) conditions.push(`NOT EXISTS (SELECT 1 FROM asset_geometries g WHERE g.asset_id=asset.id)`); const hasFilters = conditions.length > 0; const targetParent = query.parentId ?? null; const parentParameter = addParameter(targetParent); const limitParameter = addParameter(query.limit + 1); const directParent = `asset.parent_id IS NOT DISTINCT FROM ${parentParameter}::uuid`; let sql: string; if (!hasFilters) { sql = `${this.listQuery(`WHERE ${directParent}`).replace(/ORDER BY asset\.name ASC, asset\.code ASC\s*$/, '')} ORDER BY asset.name,asset.code LIMIT ${limitParameter}`; } else { const matchWhere = `WHERE ${conditions.join(' AND ')}`; sql = ` WITH RECURSIVE matched AS ( SELECT asset.id FROM assets asset ${matchWhere} ), visible(id) AS ( SELECT id FROM matched UNION SELECT parent.id FROM assets child JOIN visible current ON current.id=child.id JOIN assets parent ON parent.id=child.parent_id ) ${this.listQuery(`WHERE ${directParent} AND asset.id IN (SELECT id FROM visible)`).replace(/ORDER BY asset\.name ASC, asset\.code ASC\s*$/, '')} ORDER BY asset.name,asset.code LIMIT ${limitParameter} `; } const rows = (await this.dataSource.query(sql, parameters)) as AssetListItem[]; const hasMore = rows.length > query.limit; const data = hasMore ? rows.slice(0, query.limit) : rows; return { data, meta: { count: data.length, hasMore, parentId: targetParent } }; } async parentOptions(query: ParentOptionsQueryDto): Promise<{ data: AssetListItem[] }> { const parameters: unknown[] = [query.childTypeId]; const conditions = [ `asset.asset_type_id IN ( SELECT parent_type_id FROM asset_type_parent_rules WHERE child_type_id = $1 )`, ]; if (query.assetId) { parameters.push(query.assetId); const assetIdParameter = `$${parameters.length}`; conditions.push(`asset.id <> ${assetIdParameter}`); conditions.push(`asset.id NOT IN ( WITH RECURSIVE descendants AS ( SELECT id FROM assets WHERE parent_id = ${assetIdParameter} UNION ALL SELECT child.id FROM assets child INNER JOIN descendants current ON child.parent_id = current.id ) SELECT id FROM descendants )`); } if (query.search?.trim()) { parameters.push(`%${query.search.trim()}%`); const searchParameter = `$${parameters.length}`; conditions.push(`(asset.code ILIKE ${searchParameter} OR asset.name ILIKE ${searchParameter} OR asset.common_name ILIKE ${searchParameter})`); } const rows = (await this.dataSource.query( `${this.listQuery(`WHERE ${conditions.join(' AND ')}`)} LIMIT 50`, parameters, )) as AssetListItem[]; return { data: rows }; } async lineage(id: string): Promise<{ data: Array<{ id: string; code: string; name: string; commonName: string | null; type: { id: string; code: string; name: string } }> }> { const rows = (await this.dataSource.query( `WITH RECURSIVE lineage AS ( SELECT asset.id, asset.parent_id, 0 AS depth FROM assets asset WHERE asset.id = $1 UNION ALL SELECT parent.id, parent.parent_id, lineage.depth + 1 FROM assets parent INNER JOIN lineage ON lineage.parent_id = parent.id ) SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", json_build_object('id', asset_type.id, 'code', asset_type.code, 'name', asset_type.name) AS type, lineage.depth FROM lineage INNER JOIN assets asset ON asset.id = lineage.id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id ORDER BY lineage.depth DESC`, [id], )) as Array<{ id: string; code: string; name: string; commonName: string | null; type: { id: string; code: string; name: string }; depth: number }>; if (rows.length === 0) throw assetNotFound(); return { data: rows.map(({ depth: _depth, ...item }) => item) }; } async dossier(id: string) { const assetRows = (await this.dataSource.query( `SELECT id, code, name, common_name AS "commonName", created_at AS "createdAt" FROM assets WHERE id = $1`, [id], )) as Array<{ id: string; code: string; name: string; commonName: string | null; createdAt: Date }>; const asset = assetRows[0]; if (!asset) throw assetNotFound(); const [visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, media, versions] = await Promise.all([ this.dataSource.query( `SELECT DISTINCT visit.id, visit.code, visit.status, visit.planned_start_at AS "plannedStartAt", visit.actual_started_at AS "actualStartedAt", visit.actual_closed_at AS "actualClosedAt", visit.created_at AS "createdAt" FROM inspection_visits visit LEFT JOIN inspection_visit_assets visit_asset ON visit_asset.visit_id = visit.id AND visit_asset.included = true LEFT JOIN inspection_acts act ON act.visit_id = visit.id LEFT JOIN inspection_act_assets act_asset ON act_asset.act_id = act.id AND act_asset.included = true LEFT JOIN inspection_findings finding ON finding.act_id = act.id WHERE visit.scope_asset_id = $1 OR visit_asset.asset_id = $1 OR act_asset.asset_id = $1 OR finding.asset_id = $1 ORDER BY COALESCE(visit.actual_started_at, visit.planned_start_at, visit.created_at) DESC LIMIT 200`, [id], ), this.dataSource.query( `SELECT DISTINCT act.id, act.visit_id AS "visitId", act.code, act.status, act.occurred_at AS "occurredAt", act.title, act.summary, act.closed_at AS "closedAt", act.current_version AS "currentVersion", visit.code AS "visitCode" FROM inspection_acts act JOIN inspection_visits visit ON visit.id = act.visit_id LEFT JOIN inspection_act_assets act_asset ON act_asset.act_id = act.id AND act_asset.included = true LEFT JOIN inspection_findings finding ON finding.act_id = act.id WHERE act_asset.asset_id = $1 OR finding.asset_id = $1 ORDER BY act.occurred_at DESC LIMIT 200`, [id], ), this.dataSource.query( `SELECT finding.id, finding.act_id AS "actId", finding.code, finding.status, finding.title, finding.description, finding.correction_due_on AS "correctionDueOn", finding.company_response_received_on AS "companyResponseReceivedOn", finding.next_control_on AS "nextControlOn", finding.closed_at AS "closedAt", finding.closure_notes AS "closureNotes", finding.created_at AS "createdAt", finding.updated_at AS "updatedAt", act.code AS "actCode", act.occurred_at AS "actOccurredAt", visit.id AS "visitId", visit.code AS "visitCode" FROM inspection_findings finding JOIN inspection_acts act ON act.id = finding.act_id JOIN inspection_visits visit ON visit.id = act.visit_id WHERE finding.asset_id = $1 ORDER BY finding.created_at DESC LIMIT 500`, [id], ), this.dataSource.query( `SELECT evidence.id, evidence.finding_id AS "findingId", evidence.communication_id AS "communicationId", evidence.kind, evidence.purpose, evidence.original_name AS "originalName", evidence.title, evidence.description, evidence.captured_at AS "capturedAt", evidence.created_at AS "createdAt", finding.code AS "findingCode", finding.title AS "findingTitle" FROM inspection_finding_evidence evidence JOIN inspection_findings finding ON finding.id = evidence.finding_id WHERE finding.asset_id = $1 ORDER BY COALESCE(evidence.captured_at, evidence.created_at) DESC LIMIT 500`, [id], ), this.dataSource.query( `SELECT communication.id, communication.finding_id AS "findingId", communication.direction, communication.channel, communication.type, communication.occurred_at AS "occurredAt", communication.subject, communication.details, communication.contact_name AS "contactName", communication.created_at AS "createdAt", finding.code AS "findingCode", finding.title AS "findingTitle" FROM inspection_finding_communications communication JOIN inspection_findings finding ON finding.id = communication.finding_id WHERE finding.asset_id = $1 ORDER BY communication.occurred_at DESC LIMIT 500`, [id], ), this.dataSource.query( `SELECT verification_link.id, verification_link.finding_id AS "findingId", verification_link.visit_id AS "visitId", verification_link.target_control_on AS "targetControlOn", verification_link.outcome, verification_link.result_notes AS "resultNotes", verification_link.verified_at AS "verifiedAt", verification_link.result_recorded_at AS "resultRecordedAt", verification_link.rescheduled_control_on AS "rescheduledControlOn", finding.code AS "findingCode", finding.title AS "findingTitle", visit.code AS "visitCode", visit.status AS "visitStatus", (SELECT COUNT(*)::integer FROM inspection_finding_evidence verification_evidence WHERE verification_evidence.finding_id = finding.id AND verification_evidence.verification_visit_id = visit.id AND verification_evidence.purpose = 'VERIFICATION') AS "evidenceCount" FROM inspection_finding_verification_visits verification_link JOIN inspection_findings finding ON finding.id = verification_link.finding_id JOIN inspection_visits visit ON visit.id = verification_link.visit_id WHERE finding.asset_id = $1 AND verification_link.outcome IS NOT NULL ORDER BY verification_link.verified_at DESC NULLS LAST, verification_link.result_recorded_at DESC LIMIT 500`, [id], ), this.dataSource.query( `SELECT document.id, document.document_type AS "documentType", document.document_number AS "documentNumber", document.title, document.issuer, document.document_date AS "documentDate", document.external_reference AS "externalReference", link.relation_type AS "relationType", link.notes, link.created_at AS "linkedAt" FROM asset_source_documents link JOIN source_documents document ON document.id = link.document_id WHERE link.asset_id = $1 ORDER BY COALESCE(document.document_date::timestamptz, link.created_at) DESC LIMIT 300`, [id], ), this.dataSource.query( `SELECT DISTINCT report.id, report.code, report.status, report.pdf_status AS "pdfStatus", report.title, report.generated_at AS "generatedAt", report.frozen_sha256 AS "frozenSha256", act.id AS "actId", act.code AS "actCode", visit.id AS "visitId", visit.code AS "visitCode" FROM inspection_reports report JOIN inspection_acts act ON act.id = report.act_id JOIN inspection_visits visit ON visit.id = report.visit_id LEFT JOIN inspection_act_assets act_asset ON act_asset.act_id = act.id AND act_asset.included = true LEFT JOIN inspection_findings finding ON finding.act_id = act.id WHERE act_asset.asset_id = $1 OR finding.asset_id = $1 OR visit.scope_asset_id = $1 ORDER BY report.generated_at DESC LIMIT 200`, [id], ), this.dataSource.query( `SELECT media.id, media.kind, media.original_name AS "originalName", media.title, media.description, media.captured_at AS "capturedAt", media.created_at AS "createdAt", media.source FROM asset_media media WHERE media.asset_id = $1 AND media.deleted_at IS NULL ORDER BY COALESCE(media.captured_at, media.created_at) DESC LIMIT 500`, [id], ), this.dataSource.query( `SELECT version.id, version.version_number AS "versionNumber", version.change_type AS "changeType", version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt", version.actor_username AS "actorUsername", version.source FROM asset_versions version WHERE version.asset_id = $1 ORDER BY version.occurred_at DESC LIMIT 500`, [id], ), ]) as [ Array>, Array>, Array>, Array>, Array>, Array>, Array>, Array>, Array>, Array>, ]; const timeline: Array> = []; const push = (event: Record) => timeline.push(event); versions.forEach((version) => push({ id: `version:${String(version.id)}`, kind: 'INVENTORY_CHANGE', occurredAt: version.occurredAt, title: version.changeType === 'CREATED' || version.changeType === 'BASELINE' ? 'Registro incorporado al inventario' : 'Inventario actualizado', description: Array.isArray(version.changedFields) && version.changedFields.length > 0 ? `Campos: ${(version.changedFields as string[]).join(', ')}` : null, meta: { versionNumber: version.versionNumber, changeType: version.changeType, actorUsername: version.actorUsername, source: version.source }, })); visits.forEach((visit) => push({ id: `visit:${String(visit.id)}`, kind: 'INSPECTION', occurredAt: visit.actualStartedAt ?? visit.plannedStartAt ?? visit.createdAt, title: `Inspección ${String(visit.code)}`, description: null, href: `/inspecciones/${String(visit.id)}`, meta: { status: visit.status }, })); acts.forEach((act) => push({ id: `act:${String(act.id)}`, kind: 'ACT', occurredAt: act.occurredAt, title: `Acta ${String(act.code)}`, description: act.title, href: `/inspecciones/actas/${String(act.id)}`, meta: { status: act.status, visitCode: act.visitCode }, })); findings.forEach((finding) => { push({ id: `finding:${String(finding.id)}`, kind: 'FINDING', occurredAt: finding.createdAt, title: `Hallazgo ${String(finding.code)}`, description: finding.title, href: `/hallazgos/${String(finding.id)}`, meta: { status: finding.status, actCode: finding.actCode }, }); if (finding.closedAt) push({ id: `finding-close:${String(finding.id)}`, kind: 'FINDING_CLOSED', occurredAt: finding.closedAt, title: `Hallazgo ${String(finding.code)} cerrado`, description: finding.closureNotes, href: `/hallazgos/${String(finding.id)}`, meta: { status: finding.status }, }); }); verificationResults.forEach((verification) => push({ id: `verification:${String(verification.id)}`, kind: 'VERIFICATION', occurredAt: verification.verifiedAt ?? verification.resultRecordedAt, title: verification.outcome === 'RESOLVED' ? `Verificación conforme · ${String(verification.findingCode)}` : verification.outcome === 'NOT_RESOLVED' ? `Verificación no conforme · ${String(verification.findingCode)}` : `Verificación reprogramada · ${String(verification.findingCode)}`, description: verification.resultNotes, href: `/hallazgos/${String(verification.findingId)}`, meta: { outcome: verification.outcome, visitCode: verification.visitCode, targetControlOn: verification.targetControlOn, rescheduledControlOn: verification.rescheduledControlOn, evidenceCount: verification.evidenceCount, }, })); communications.forEach((communication) => push({ id: `communication:${String(communication.id)}`, kind: 'COMMUNICATION', occurredAt: communication.occurredAt, title: communication.type === 'COMPANY_RESPONSE' ? 'Respuesta de la empresa' : String(communication.subject), description: communication.details, href: `/hallazgos/${String(communication.findingId)}`, meta: { findingCode: communication.findingCode, direction: communication.direction, channel: communication.channel }, })); evidence.forEach((item) => push({ id: `evidence:${String(item.id)}`, kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT', occurredAt: item.capturedAt ?? item.createdAt, title: item.kind === 'PHOTO' ? 'Fotografía / evidencia' : 'Documento incorporado', description: item.title ?? item.originalName, href: `/hallazgos/${String(item.findingId)}`, meta: { findingCode: item.findingCode, purpose: item.purpose }, })); inspectionReports.forEach((report) => push({ id: `inspection-report:${String(report.id)}`, kind: 'REPORT', occurredAt: report.generatedAt, title: `Informe ${String(report.code)}`, description: report.title, href: `/informes/${String(report.id)}`, meta: { status: report.status, pdfStatus: report.pdfStatus, actCode: report.actCode }, })); documents.forEach((document) => push({ id: `source-document:${String(document.id)}`, kind: document.documentType === 'TECHNICAL_REPORT' ? 'REPORT' : 'SOURCE_DOCUMENT', occurredAt: document.documentDate ?? document.linkedAt, title: String(document.title), description: document.documentNumber ? `Documento ${String(document.documentNumber)}` : 'Documento vinculado al inventario', meta: { documentType: document.documentType, relationType: document.relationType }, })); media.forEach((item) => push({ id: `asset-media:${String(item.id)}`, kind: item.kind === 'PHOTO' ? 'PHOTO' : 'DOCUMENT', occurredAt: item.capturedAt ?? item.createdAt, title: item.kind === 'PHOTO' ? 'Fotografía del inventario' : 'Archivo del inventario', description: item.title ?? item.originalName, meta: { source: item.source }, })); timeline.sort((a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime()); const openFindings = findings.filter((finding) => finding.status === 'OPEN').length; const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length; const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT'); return { asset: { id: asset.id, code: asset.code, name: asset.name, commonName: asset.commonName }, counters: { inspections: visits.length, acts: acts.length, findings: findings.length, verifications: verificationResults.length, openFindings, closedFindings, evidence: evidence.length, documents: documents.length + media.filter((item) => item.kind === 'DOCUMENT').length, photos: evidence.filter((item) => item.kind === 'PHOTO').length + media.filter((item) => item.kind === 'PHOTO').length, reports: reports.length + inspectionReports.length, }, visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, reports, media, versions, timeline: timeline.slice(0, 500), }; } async getById(id: string): Promise { return this.dataSource.transaction((manager) => this.loadView(manager, id)); } async create( dto: CreateAssetDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { try { return await this.dataSource.transaction(async (manager) => { const type = await this.requireActiveType(manager, dto.typeId); await this.validateParent(manager, type, dto.parentId ?? null, null); await this.validateOperationalAssignment( manager, type, dto.parentId ?? null, dto.operationalAreaId ?? null, dto.operatorCompanyId ?? null, ); const definitions = await this.loadDefinitions(manager, type.id); const values = validateAssetAttributeValues(definitions, dto.attributes); const asset = manager.getRepository(Asset).create({ assetTypeId: type.id, parentId: dto.parentId ?? null, operationalAreaId: dto.operationalAreaId ?? null, operatorCompanyId: dto.operatorCompanyId ?? null, code: dto.code, name: dto.name, commonName: dto.commonName ?? null, description: dto.description ?? null, informationStatus: principal.permissions.includes('assets.change_status') ? dto.informationStatus : AssetInformationStatus.DRAFT, createdBy: principal.userId, updatedBy: principal.userId, dataOrigin: AssetDataOrigin.MANUAL, provenanceUpdatedBy: principal.userId, }); await manager.getRepository(Asset).save(asset); if (type.operationalRole === AssetTypeOperationalRole.COMPANY) { await manager.getRepository(OrganizationProfile).save( manager.getRepository(OrganizationProfile).create({ assetId: asset.id, organizationKind: OrganizationKind.COMPANY, legalName: asset.name, taxId: null, notes: null, updatedBy: principal.userId, }), ); } await this.replaceAttributeValues(manager, asset.id, values, principal.userId); const versionNumber = await this.history.capture( manager, asset.id, AssetVersionChangeType.CREATED, principal, request, ); await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto inicial del registro'); const created = await this.loadView(manager, asset.id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ASSET_CREATED, entityType: 'asset', entityId: asset.id, afterData: this.auditView(created), metadata: { versionNumber }, }, manager, ); return created; }); } catch (error) { if (isUniqueViolation(error)) throw this.assetConflict(); throw error; } } async listFieldDiscoveries(query: ListFieldDiscoveriesQueryDto) { const conditions: string[] = []; const parameters: unknown[] = []; const add = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; if (query.status) conditions.push(`discovery.status = ${add(query.status)}`); else conditions.push(`discovery.status = 'PENDING'`); if (query.search?.trim()) { const search = add(`%${query.search.trim()}%`); conditions.push(`(asset.code ILIKE ${search} OR asset.name ILIKE ${search} OR asset.common_name ILIKE ${search} OR visit.code ILIKE ${search} OR creator.username ILIKE ${search})`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const [countRow] = (await this.dataSource.query(` SELECT COUNT(*)::integer AS total FROM asset_field_discoveries discovery JOIN assets asset ON asset.id = discovery.asset_id JOIN inspection_visits visit ON visit.id = discovery.visit_id JOIN users creator ON creator.id = discovery.created_by ${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.fieldDiscoverySelect()} ${where} ORDER BY CASE discovery.status WHEN 'PENDING' THEN 1 ELSE 2 END, discovery.created_at ASC LIMIT ${limit} OFFSET ${offset} `, parameters); return { data, meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize) } }; } async createFieldDiscovery( dto: CreateFieldDiscoveryDto, principal: AuthPrincipal, request: RequestWithContext, ) { assertMobileInspector(principal); try { return await this.dataSource.transaction(async (manager) => { const [visit] = (await manager.query(` SELECT visit.id, visit.code, visit.status, (visit.lead_inspector_user_id = $2 OR EXISTS ( SELECT 1 FROM inspection_visit_members member WHERE member.visit_id = visit.id AND member.user_id = $2 AND member.included = true )) AS assigned FROM inspection_visits visit WHERE visit.id = $1 FOR UPDATE `, [dto.visitId, principal.userId])) as Array<{ id: string; code: string; status: string; assigned: boolean }>; if (!visit) throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' }); if (visit.status !== 'IN_PROGRESS') throw new ConflictException({ code: 'FIELD_DISCOVERY_VISIT_NOT_IN_PROGRESS', message: 'Sólo se pueden registrar elementos nuevos durante una inspección en curso' }); if (!visit.assigned) throw new ConflictException({ code: 'FIELD_DISCOVERY_INSPECTOR_NOT_ASSIGNED', message: 'El inspector no está asignado a esta inspección' }); const type = await this.requireActiveType(manager, dto.typeId); await this.validateParent(manager, type, dto.parentId, null); await this.validateOperationalAssignment(manager, type, dto.parentId, dto.operationalAreaId, dto.operatorCompanyId); const definitions = await this.loadDefinitions(manager, type.id); const values = validateAssetAttributeValues(definitions, dto.attributes); const asset = manager.getRepository(Asset).create({ assetTypeId: type.id, parentId: dto.parentId, operationalAreaId: dto.operationalAreaId, operatorCompanyId: dto.operatorCompanyId, code: dto.code, name: dto.name, commonName: dto.commonName ?? null, description: dto.description ?? null, informationStatus: AssetInformationStatus.DRAFT, createdBy: principal.userId, updatedBy: principal.userId, dataOrigin: AssetDataOrigin.FIELD_SURVEY, sourceName: `Inspección ${visit.code}`, sourceReference: `inspection-visit:${visit.id}`, sourceObservedAt: new Date(), sourceNotes: dto.discoveryNotes ?? null, provenanceUpdatedBy: principal.userId, }); await manager.getRepository(Asset).save(asset); await this.replaceAttributeValues(manager, asset.id, values, principal.userId); const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.CREATED, principal, request); await this.insertInitialContextHistory(manager, asset, versionNumber, principal, request, 'Contexto observado en alta de campo'); const inspectionLinks = await this.fieldDiscoveryInspectionLinks.attach( manager, visit.id, asset.id, principal.userId, ); const [discovery] = (await manager.query(` INSERT INTO asset_field_discoveries (asset_id, visit_id, discovery_notes, observed_at, created_by) VALUES ($1,$2,$3,CURRENT_TIMESTAMP,$4) RETURNING id `, [asset.id, visit.id, dto.discoveryNotes ?? null, principal.userId])) as Array<{ id: string }>; const created = await this.loadView(manager, asset.id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FIELD_DISCOVERY_CREATED, entityType: 'asset_field_discovery', entityId: discovery.id, afterData: this.auditView(created), metadata: { assetId: asset.id, visitId: visit.id, versionNumber, addedToActId: inspectionLinks.actId }, }, manager); return this.loadFieldDiscovery(manager, discovery.id); }); } catch (error) { if (isUniqueViolation(error)) throw this.assetConflict(); throw error; } } async approveFieldDiscovery(id: string, dto: ReviewFieldDiscoveryDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const discovery = await this.lockPendingFieldDiscovery(manager, id); const asset = await manager.getRepository(Asset).createQueryBuilder('asset').where('asset.id=:id', { id: discovery.assetId }).setLock('pessimistic_write').getOne(); if (!asset) throw assetNotFound(); if (!asset.operatorCompanyId || !asset.operationalAreaId || !asset.assetTypeId || !asset.code || !asset.name) { throw new ConflictException({ code: 'FIELD_DISCOVERY_MINIMUM_CONTEXT_MISSING', message: 'Antes de validar deben estar definidos Empresa, Área, Tipo de elemento y Nombre o código' }); } asset.informationStatus = AssetInformationStatus.VALIDATED; asset.updatedBy = principal.userId; await manager.getRepository(Asset).save(asset); const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.STATUS_CHANGED, principal, request); await manager.query(`UPDATE asset_field_discoveries SET status='APPROVED', reviewed_at=CURRENT_TIMESTAMP, reviewed_by=$2, review_notes=$3, updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, principal.userId, dto.notes ?? null]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FIELD_DISCOVERY_APPROVED, entityType: 'asset_field_discovery', entityId: id, afterData: { assetId: asset.id, status: 'APPROVED' }, metadata: { versionNumber } }, manager); return this.loadFieldDiscovery(manager, id); }); } async rejectFieldDiscovery(id: string, dto: RejectFieldDiscoveryDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const discovery = await this.lockPendingFieldDiscovery(manager, id); const asset = await manager.getRepository(Asset).createQueryBuilder('asset').where('asset.id=:id', { id: discovery.assetId }).setLock('pessimistic_write').getOne(); if (!asset) throw assetNotFound(); asset.informationStatus = AssetInformationStatus.INACTIVE; asset.updatedBy = principal.userId; await manager.getRepository(Asset).save(asset); const versionNumber = await this.history.capture(manager, asset.id, AssetVersionChangeType.STATUS_CHANGED, principal, request); await manager.query(`UPDATE asset_field_discoveries SET status='REJECTED', reviewed_at=CURRENT_TIMESTAMP, reviewed_by=$2, review_notes=$3, updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, principal.userId, dto.reason]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FIELD_DISCOVERY_REJECTED, entityType: 'asset_field_discovery', entityId: id, afterData: { assetId: asset.id, status: 'REJECTED', reason: dto.reason }, metadata: { versionNumber } }, manager); return this.loadFieldDiscovery(manager, id); }); } async matchFieldDiscovery(id: string, dto: MatchFieldDiscoveryDto, principal: AuthPrincipal, request: RequestWithContext) { return this.dataSource.transaction(async (manager) => { const discovery = await this.lockPendingFieldDiscovery(manager, id); if (dto.matchedAssetId === discovery.assetId) throw new BadRequestException({ code: 'FIELD_DISCOVERY_SELF_MATCH', message: 'No se puede conciliar un registro consigo mismo' }); const [source, target] = await Promise.all([ manager.getRepository(Asset).findOne({ where: { id: discovery.assetId } }), manager.getRepository(Asset).findOne({ where: { id: dto.matchedAssetId } }), ]); if (!source) throw assetNotFound(); if (!target || target.informationStatus === AssetInformationStatus.INACTIVE) throw new NotFoundException({ code: 'FIELD_DISCOVERY_MATCH_TARGET_NOT_FOUND', message: 'El registro elegido para conciliar no está disponible' }); const [targetPending] = (await manager.query(`SELECT id FROM asset_field_discoveries WHERE asset_id=$1 AND status='PENDING' LIMIT 1`, [target.id])) as Array<{ id: string }>; if (targetPending) throw new ConflictException({ code: 'FIELD_DISCOVERY_MATCH_TARGET_PROVISIONAL', message: 'No se puede conciliar contra otra alta de campo todavía pendiente' }); if (source.operationalAreaId !== target.operationalAreaId || source.operatorCompanyId !== target.operatorCompanyId) { throw new ConflictException({ code: 'FIELD_DISCOVERY_MATCH_CONTEXT_MISMATCH', message: 'La coincidencia debe pertenecer a la misma Empresa y Área' }); } source.informationStatus = AssetInformationStatus.INACTIVE; source.updatedBy = principal.userId; await manager.getRepository(Asset).save(source); const versionNumber = await this.history.capture(manager, source.id, AssetVersionChangeType.STATUS_CHANGED, principal, request); await manager.query(`UPDATE asset_field_discoveries SET status='MATCHED', matched_asset_id=$2, reviewed_at=CURRENT_TIMESTAMP, reviewed_by=$3, review_notes=$4, updated_at=CURRENT_TIMESTAMP WHERE id=$1`, [id, target.id, principal.userId, dto.reason]); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_FIELD_DISCOVERY_MATCHED, entityType: 'asset_field_discovery', entityId: id, afterData: { assetId: source.id, matchedAssetId: target.id, status: 'MATCHED', reason: dto.reason }, metadata: { versionNumber } }, manager); return this.loadFieldDiscovery(manager, id); }); } private fieldDiscoverySelect(): string { return `SELECT discovery.id, discovery.status, discovery.discovery_notes AS "discoveryNotes", discovery.observed_at AS "observedAt", discovery.reviewed_at AS "reviewedAt", discovery.review_notes AS "reviewNotes", discovery.created_at AS "createdAt", JSONB_BUILD_OBJECT('id',asset.id,'code',asset.code,'name',asset.name,'commonName',asset.common_name,'informationStatus',asset.information_status,'typeId',asset.asset_type_id,'typeName',asset_type.name,'parentId',asset.parent_id,'operationalAreaId',asset.operational_area_id,'operatorCompanyId',asset.operator_company_id) AS asset, JSONB_BUILD_OBJECT('id',visit.id,'code',visit.code,'status',visit.status) AS visit, JSONB_BUILD_OBJECT('id',creator.id,'username',creator.username,'firstName',creator.first_name,'lastName',creator.last_name) AS creator, CASE WHEN reviewer.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',reviewer.id,'username',reviewer.username,'firstName',reviewer.first_name,'lastName',reviewer.last_name) END AS reviewer, CASE WHEN matched.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',matched.id,'code',matched.code,'name',matched.name,'commonName',matched.common_name) END AS "matchedAsset" FROM asset_field_discoveries discovery JOIN assets asset ON asset.id=discovery.asset_id JOIN asset_types asset_type ON asset_type.id=asset.asset_type_id JOIN inspection_visits visit ON visit.id=discovery.visit_id JOIN users creator ON creator.id=discovery.created_by LEFT JOIN users reviewer ON reviewer.id=discovery.reviewed_by LEFT JOIN assets matched ON matched.id=discovery.matched_asset_id`; } private async loadFieldDiscovery(manager: EntityManager, id: string) { const [row] = await manager.query(`${this.fieldDiscoverySelect()} WHERE discovery.id=$1`, [id]); if (!row) throw new NotFoundException({ code: 'FIELD_DISCOVERY_NOT_FOUND', message: 'Alta de campo no encontrada' }); return row; } private async lockPendingFieldDiscovery(manager: EntityManager, id: string): Promise<{ id: string; assetId: string }> { const [row] = (await manager.query(`SELECT id, asset_id AS "assetId", status FROM asset_field_discoveries WHERE id=$1 FOR UPDATE`, [id])) as Array<{ id: string; assetId: string; status: string }>; if (!row) throw new NotFoundException({ code: 'FIELD_DISCOVERY_NOT_FOUND', message: 'Alta de campo no encontrada' }); if (row.status !== 'PENDING') throw new ConflictException({ code: 'FIELD_DISCOVERY_ALREADY_REVIEWED', message: 'Esta alta de campo ya fue resuelta' }); return row; } async contextHistory(id: string) { const [asset] = (await this.dataSource.query(`SELECT id FROM assets WHERE id=$1`, [id])) as Array<{ id: string }>; if (!asset) throw assetNotFound(); const data = await this.dataSource.query(` SELECT history.id, history.asset_id AS "assetId", history.valid_from AS "validFrom", history.valid_until AS "validUntil", history.change_reason AS "changeReason", history.end_reason AS "endReason", history.asset_version_number AS "assetVersionNumber", history.source, history.request_id AS "requestId", history.created_at AS "createdAt", CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany", 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 creator, (history.valid_until IS NULL) AS "isCurrent" FROM asset_context_history history LEFT JOIN assets parent ON parent.id=history.parent_id LEFT JOIN assets area ON area.id=history.operational_area_id LEFT JOIN assets company ON company.id=history.operator_company_id LEFT JOIN users creator ON creator.id=history.created_by WHERE history.asset_id=$1 ORDER BY history.valid_from DESC, history.created_at DESC `, [id]); return { data }; } async changeContext( id: string, dto: ChangeAssetContextDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const repository = manager.getRepository(Asset); const asset = await repository.createQueryBuilder('asset').where('asset.id=:id', { id }).setLock('pessimistic_write').getOne(); if (!asset) throw assetNotFound(); const before = await this.loadView(manager, id); const type = await this.requireType(manager, asset.assetTypeId); const nextParentId = dto.parentId === undefined ? asset.parentId : dto.parentId; const nextOperationalAreaId = dto.operationalAreaId === undefined ? asset.operationalAreaId : dto.operationalAreaId; const nextOperatorCompanyId = dto.operatorCompanyId === undefined ? asset.operatorCompanyId : dto.operatorCompanyId; const contextChanged = nextParentId !== asset.parentId || nextOperationalAreaId !== asset.operationalAreaId || nextOperatorCompanyId !== asset.operatorCompanyId; if (!contextChanged) { throw new BadRequestException({ code: 'NO_CONTEXT_CHANGES', message: 'El contexto indicado coincide con el contexto actual' }); } await this.validateParent(manager, type, nextParentId ?? null, id); await this.validateOperationalAssignment(manager, type, nextParentId ?? null, nextOperationalAreaId ?? null, nextOperatorCompanyId ?? null); if (nextParentId !== asset.parentId) await this.assertHierarchyMoveKeepsOperationalAssignments(manager, id, nextParentId ?? null); const effectiveAt = dto.effectiveAt ?? new Date(); if (effectiveAt.getTime() > Date.now() + 60_000) { throw new BadRequestException({ code: 'ASSET_CONTEXT_FUTURE_DATE', message: 'La vigencia del cambio no puede comenzar en el futuro' }); } const [currentHistory] = (await manager.query(` SELECT id, valid_from AS "validFrom" FROM asset_context_history WHERE asset_id=$1 AND valid_until IS NULL FOR UPDATE `, [id])) as Array<{ id: string; validFrom: Date }>; if (!currentHistory) { throw new ConflictException({ code: 'ASSET_CONTEXT_HISTORY_MISSING', message: 'El registro no tiene una asignación de contexto vigente' }); } if (effectiveAt.getTime() <= new Date(currentHistory.validFrom).getTime()) { throw new ConflictException({ code: 'ASSET_CONTEXT_EFFECTIVE_DATE_INVALID', message: 'La nueva vigencia debe ser posterior al inicio del contexto actual' }); } asset.parentId = nextParentId ?? null; asset.operationalAreaId = nextOperationalAreaId ?? null; asset.operatorCompanyId = nextOperatorCompanyId ?? null; asset.updatedBy = principal.userId; await repository.save(asset); const versionNumber = await this.history.capture(manager, id, AssetVersionChangeType.CONTEXT_CHANGED, principal, request); await this.appendContextTransition( manager, id, nextParentId ?? null, nextOperationalAreaId ?? null, nextOperatorCompanyId ?? null, effectiveAt, dto.reason, versionNumber, principal, request, ); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_CONTEXT_CHANGED, entityType: 'asset', entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), metadata: { versionNumber, effectiveAt: effectiveAt.toISOString(), reason: dto.reason }, }, manager); return updated; }); } private async insertInitialContextHistory( manager: EntityManager, asset: Asset, versionNumber: number, principal: AuthPrincipal, request: RequestWithContext, reason: string, ): Promise { await manager.query(` INSERT INTO asset_context_history ( asset_id, parent_id, operational_area_id, operator_company_id, valid_from, change_reason, asset_version_number, source, request_id, created_by ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) ON CONFLICT DO NOTHING `, [ asset.id, asset.parentId, asset.operationalAreaId, asset.operatorCompanyId, asset.createdAt ?? new Date(), reason, versionNumber, principal.transport === 'bearer' ? 'ANDROID' : 'WEB', request.requestId, principal.userId, ]); } private async appendContextTransition( manager: EntityManager, assetId: string, parentId: string | null, operationalAreaId: string | null, operatorCompanyId: string | null, effectiveAt: Date, reason: string, versionNumber: number, principal: AuthPrincipal, request: RequestWithContext, ): Promise { const [current] = (await manager.query(` SELECT id, valid_from AS "validFrom" FROM asset_context_history WHERE asset_id=$1 AND valid_until IS NULL FOR UPDATE `, [assetId])) as Array<{ id: string; validFrom: Date }>; if (!current) { throw new ConflictException({ code: 'ASSET_CONTEXT_HISTORY_MISSING', message: 'El registro no tiene una asignación de contexto vigente' }); } const safeEffectiveAt = effectiveAt.getTime() <= new Date(current.validFrom).getTime() ? new Date(new Date(current.validFrom).getTime() + 1) : effectiveAt; await manager.query(` UPDATE asset_context_history SET valid_until=$2, end_reason=$3, ended_by=$4, ended_at=CURRENT_TIMESTAMP WHERE id=$1 `, [current.id, safeEffectiveAt, reason, principal.userId]); await manager.query(` INSERT INTO asset_context_history ( asset_id, parent_id, operational_area_id, operator_company_id, valid_from, change_reason, asset_version_number, source, request_id, created_by ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) `, [ assetId, parentId, operationalAreaId, operatorCompanyId, safeEffectiveAt, reason, versionNumber, principal.transport === 'bearer' ? 'ANDROID' : 'WEB', request.requestId, principal.userId, ]); } async update( id: string, dto: UpdateAssetDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { if (Object.keys(dto).length === 0) { throw new BadRequestException({ code: 'NO_CHANGES', message: 'No se recibieron cambios' }); } try { return await this.dataSource.transaction(async (manager) => { const repository = manager.getRepository(Asset); const asset = await repository .createQueryBuilder('asset') .where('asset.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!asset) throw assetNotFound(); const before = await this.loadView(manager, id); const currentType = await this.requireType(manager, asset.assetTypeId); let type = currentType; const typeChanged = dto.typeId !== undefined && dto.typeId !== asset.assetTypeId; const nextParentId = dto.parentId === undefined ? asset.parentId : dto.parentId; const nextOperationalAreaId = dto.operationalAreaId === undefined ? asset.operationalAreaId : dto.operationalAreaId; const nextOperatorCompanyId = dto.operatorCompanyId === undefined ? asset.operatorCompanyId : dto.operatorCompanyId; const contextChanged = nextParentId !== asset.parentId || nextOperationalAreaId !== asset.operationalAreaId || nextOperatorCompanyId !== asset.operatorCompanyId; let pendingDiscovery: { id: string } | undefined; if (typeChanged || contextChanged) { [pendingDiscovery] = (await manager.query(`SELECT id FROM asset_field_discoveries WHERE asset_id=$1 AND status='PENDING' LIMIT 1`, [id])) as Array<{ id: string }>; } const canCorrectPendingDiscovery = asset.dataOrigin === AssetDataOrigin.FIELD_SURVEY && asset.informationStatus === AssetInformationStatus.DRAFT && Boolean(pendingDiscovery); if (typeChanged) { if (!canCorrectPendingDiscovery) { throw new ConflictException({ code: 'ASSET_TYPE_CHANGE_NOT_ALLOWED', message: 'El tipo sólo puede corregirse mientras una alta de campo está pendiente de revisión' }); } type = await this.requireActiveType(manager, dto.typeId!); } if (contextChanged && !canCorrectPendingDiscovery) { throw new ConflictException({ code: 'ASSET_CONTEXT_CHANGE_REQUIRES_TRANSFER', message: 'Los cambios de jerarquía, Área u Operadora deben registrarse desde Cambiar contexto para conservar la historia', }); } await this.validateParent(manager, type, nextParentId ?? null, id); await this.validateOperationalAssignment( manager, type, nextParentId ?? null, nextOperationalAreaId ?? null, nextOperatorCompanyId ?? null, ); if (dto.parentId !== undefined && nextParentId !== asset.parentId) { await this.assertHierarchyMoveKeepsOperationalAssignments( manager, id, nextParentId ?? null, ); } if (typeChanged) asset.assetTypeId = type.id; if (dto.code !== undefined) asset.code = dto.code; if (dto.name !== undefined) asset.name = dto.name; if (dto.commonName !== undefined) asset.commonName = dto.commonName; if (dto.parentId !== undefined) asset.parentId = dto.parentId; if (dto.operationalAreaId !== undefined) asset.operationalAreaId = dto.operationalAreaId; if (dto.operatorCompanyId !== undefined) asset.operatorCompanyId = dto.operatorCompanyId; if (dto.description !== undefined) asset.description = dto.description; asset.updatedBy = principal.userId; await repository.save(asset); if (dto.attributes !== undefined) { const definitions = await this.loadDefinitions(manager, type.id); const values = validateAssetAttributeValues(definitions, dto.attributes); await this.replaceAttributeValues(manager, id, values, principal.userId); } const versionNumber = await this.history.capture( manager, id, AssetVersionChangeType.UPDATED, principal, request, ); if (contextChanged) { await this.appendContextTransition( manager, id, nextParentId ?? null, nextOperationalAreaId ?? null, nextOperatorCompanyId ?? null, new Date(), 'Corrección de contexto durante revisión de alta de campo', versionNumber, principal, request, ); } const updated = await this.loadView(manager, id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ASSET_UPDATED, entityType: 'asset', entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), metadata: { versionNumber }, }, manager, ); return updated; }); } catch (error) { if (isUniqueViolation(error)) throw this.assetConflict(); throw error; } } async changeStatus( id: string, dto: ChangeAssetStatusDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const repository = manager.getRepository(Asset); const asset = await repository .createQueryBuilder('asset') .where('asset.id = :id', { id }) .setLock('pessimistic_write') .getOne(); if (!asset) throw assetNotFound(); const beforeStatus = asset.informationStatus; if (beforeStatus === dto.informationStatus) return this.loadView(manager, id); if (dto.informationStatus === AssetInformationStatus.INACTIVE) { await this.assertOperationalAnchorCanBeInactivated(manager, asset); } asset.informationStatus = dto.informationStatus; asset.updatedBy = principal.userId; await repository.save(asset); const versionNumber = await this.history.capture( manager, id, AssetVersionChangeType.STATUS_CHANGED, principal, request, ); const updated = await this.loadView(manager, id); await this.audit.record( { ...administrationAuditContext(principal, request), action: AuditAction.ASSET_INFORMATION_STATUS_CHANGED, entityType: 'asset', entityId: id, beforeData: { informationStatus: beforeStatus }, afterData: { informationStatus: dto.informationStatus }, metadata: { versionNumber }, }, manager, ); return updated; }); } async changeOperationalStatus( id: string, dto: ChangeAssetOperationalStatusDto, principal: AuthPrincipal, request: RequestWithContext, ): Promise { return this.dataSource.transaction(async (manager) => { const repository = manager.getRepository(Asset); const asset = await repository.createQueryBuilder('asset').where('asset.id = :id', { id }).setLock('pessimistic_write').getOne(); if (!asset) throw assetNotFound(); const beforeStatus = asset.operationalStatus; if (beforeStatus === dto.status) return this.loadView(manager, id); asset.operationalStatus = dto.status; asset.updatedBy = principal.userId; await repository.save(asset); const versionNumber = await this.history.capture(manager, id, AssetVersionChangeType.OPERATIONAL_STATUS_CHANGED, principal, request); const updated = await this.loadView(manager, id); await this.audit.record({ ...administrationAuditContext(principal, request), action: AuditAction.ASSET_OPERATIONAL_STATUS_CHANGED, entityType: 'asset', entityId: id, beforeData: { operationalStatus: beforeStatus }, afterData: { operationalStatus: dto.status }, metadata: { versionNumber }, }, manager); return updated; }); } private async requireActiveType(manager: EntityManager, id: string): Promise { const type = await this.requireType(manager, id); if (!type.isActive) { throw new ConflictException({ code: 'ASSET_TYPE_INACTIVE', message: 'El tipo de activo está inactivo', }); } return type; } private async requireType(manager: EntityManager, id: string): Promise { const type = await manager.getRepository(AssetType).findOne({ where: { id } }); if (!type) { throw new BadRequestException({ code: 'ASSET_TYPE_NOT_FOUND', message: 'El tipo de activo no existe', }); } return type; } private async validateParent( manager: EntityManager, type: AssetType, parentId: string | null, assetId: string | null, ): Promise { if (!parentId) { if (!type.canBeRoot) { throw new BadRequestException({ code: 'ASSET_PARENT_REQUIRED', message: 'El tipo seleccionado requiere un activo padre', }); } return; } if (parentId === assetId) { throw new BadRequestException({ code: 'ASSET_HIERARCHY_CYCLE', message: 'Un activo no puede ser su propio padre', }); } const parent = await manager.getRepository(Asset).findOne({ where: { id: parentId } }); if (!parent) { throw new BadRequestException({ code: 'ASSET_PARENT_NOT_FOUND', message: 'El activo padre no existe', }); } const [rule] = (await manager.query( `SELECT 1 FROM asset_type_parent_rules WHERE child_type_id = $1 AND parent_type_id = $2`, [type.id, parent.assetTypeId], )) as unknown[]; if (!rule) { throw new BadRequestException({ code: 'ASSET_PARENT_TYPE_NOT_ALLOWED', message: 'El tipo del activo padre no está permitido para esta jerarquía', }); } if (assetId) { const [cycle] = (await manager.query( `WITH RECURSIVE ancestors AS ( SELECT id, parent_id FROM assets WHERE id = $1 UNION ALL SELECT parent.id, parent.parent_id FROM assets parent INNER JOIN ancestors current ON parent.id = current.parent_id ) SELECT 1 FROM ancestors WHERE id = $2 LIMIT 1`, [parentId, assetId], )) as unknown[]; if (cycle) { throw new BadRequestException({ code: 'ASSET_HIERARCHY_CYCLE', message: 'La relación generaría un ciclo en la jerarquía', }); } } } private async validateOperationalAssignment( manager: EntityManager, type: AssetType, parentId: string | null, operationalAreaId: string | null, operatorCompanyId: string | null, ): Promise { if (!operationalAreaId && !operatorCompanyId) return; if (!operationalAreaId || !operatorCompanyId) { throw new BadRequestException({ code: 'ASSET_OPERATIONAL_CONTEXT_INCOMPLETE', message: 'El área operativa y la organización operadora deben asignarse juntas', }); } if (type.operationalRole !== AssetTypeOperationalRole.GENERIC) { throw new BadRequestException({ code: 'ASSET_OPERATIONAL_CONTEXT_NOT_ALLOWED', message: 'Los activos configurados como Área u Organización no reciben una asignación operativa', }); } const [context] = (await manager.query(` SELECT area_type.operational_role AS "areaRole", area_type.is_active AS "areaTypeActive", area.information_status AS "areaStatus", company_type.operational_role AS "companyRole", company_type.is_active AS "companyTypeActive", company.information_status AS "companyStatus" FROM assets area INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id CROSS JOIN assets company INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id WHERE area.id = $1 AND company.id = $2 `, [operationalAreaId, operatorCompanyId])) as Array<{ areaRole: AssetTypeOperationalRole; areaTypeActive: boolean; areaStatus: AssetInformationStatus; companyRole: AssetTypeOperationalRole; companyTypeActive: boolean; companyStatus: AssetInformationStatus; }>; if (!context) { throw new BadRequestException({ code: 'ASSET_OPERATIONAL_CONTEXT_NOT_FOUND', message: 'El área o la organización seleccionada no existe', }); } if (context.areaRole !== AssetTypeOperationalRole.AREA) { throw new BadRequestException({ code: 'ASSET_OPERATIONAL_AREA_INVALID', message: 'El activo seleccionado como área no tiene el rol operativo Área', }); } if (context.companyRole !== AssetTypeOperationalRole.COMPANY) { throw new BadRequestException({ code: 'ASSET_OPERATOR_COMPANY_INVALID', message: 'El activo seleccionado como organización no tiene el rol interno requerido', }); } if ( !context.areaTypeActive || !context.companyTypeActive || context.areaStatus === AssetInformationStatus.INACTIVE || context.companyStatus === AssetInformationStatus.INACTIVE ) { throw new ConflictException({ code: 'ASSET_OPERATIONAL_CONTEXT_INACTIVE', message: 'El área, la organización y sus tipos deben estar activos para asignar activos', }); } const relationRows = (await manager.query(` SELECT id FROM area_company_relations WHERE area_id = $1 AND company_id = $2 AND relation_role = 'OPERATOR' AND valid_until IS NULL FOR KEY SHARE `, [operationalAreaId, operatorCompanyId])) as Array<{ id: string }>; if (relationRows.length === 0) { throw new BadRequestException({ code: 'ASSET_OPERATIONAL_RELATION_REQUIRED', message: 'La organización no tiene un rol Operadora activo en el área seleccionada', }); } if (!parentId) { throw new BadRequestException({ code: 'ASSET_OPERATIONAL_AREA_OUTSIDE_HIERARCHY', message: 'El activo debe estar contenido físicamente dentro del área operativa seleccionada', }); } const [inside] = (await manager.query(` WITH RECURSIVE ancestors AS ( SELECT id, parent_id FROM assets WHERE id = $1 UNION ALL SELECT parent.id, parent.parent_id FROM assets parent INNER JOIN ancestors current ON parent.id = current.parent_id ) SELECT 1 FROM ancestors WHERE id = $2 LIMIT 1 `, [parentId, operationalAreaId])) as unknown[]; if (!inside) { throw new BadRequestException({ code: 'ASSET_OPERATIONAL_AREA_OUTSIDE_HIERARCHY', message: 'El área operativa seleccionada debe formar parte de la jerarquía física del activo', }); } } private async assertHierarchyMoveKeepsOperationalAssignments( manager: EntityManager, assetId: string, nextParentId: string | null, ): Promise { const [invalid] = (await manager.query(` WITH RECURSIVE subtree AS ( SELECT id, code, name, parent_id, operational_area_id FROM assets WHERE id = $1 UNION ALL SELECT child.id, child.code, child.name, child.parent_id, child.operational_area_id FROM assets child INNER JOIN subtree parent ON child.parent_id = parent.id ), lineage AS ( SELECT subtree.id AS asset_id, CASE WHEN subtree.id = $1 THEN $2::uuid ELSE subtree.parent_id END AS ancestor_id FROM subtree WHERE CASE WHEN subtree.id = $1 THEN $2::uuid ELSE subtree.parent_id END IS NOT NULL UNION ALL SELECT lineage.asset_id, CASE WHEN parent.id = $1 THEN $2::uuid ELSE parent.parent_id END AS ancestor_id FROM lineage INNER JOIN assets parent ON parent.id = lineage.ancestor_id WHERE CASE WHEN parent.id = $1 THEN $2::uuid ELSE parent.parent_id END IS NOT NULL ) SELECT subtree.id, subtree.code, subtree.name FROM subtree WHERE subtree.operational_area_id IS NOT NULL AND NOT EXISTS ( SELECT 1 FROM lineage WHERE lineage.asset_id = subtree.id AND lineage.ancestor_id = subtree.operational_area_id ) LIMIT 1 `, [assetId, nextParentId])) as Array<{ id: string; code: string; name: string }>; if (invalid) { throw new ConflictException({ code: 'ASSET_HIERARCHY_MOVE_BREAKS_OPERATIONAL_CONTEXT', message: `El cambio de jerarquía dejaría al activo ${invalid.code} · ${invalid.name} fuera de su área operativa`, assetId: invalid.id, }); } } private async assertOperationalAnchorCanBeInactivated( manager: EntityManager, asset: Asset, ): Promise { const type = await this.requireType(manager, asset.assetTypeId); if (type.operationalRole === AssetTypeOperationalRole.GENERIC) return; const relationColumn = type.operationalRole === AssetTypeOperationalRole.AREA ? 'area_id' : 'company_id'; const assignmentColumn = type.operationalRole === AssetTypeOperationalRole.AREA ? 'operational_area_id' : 'operator_company_id'; const [usage] = (await manager.query(` SELECT (SELECT COUNT(*)::integer FROM area_company_relations WHERE ${relationColumn} = $1 AND valid_until IS NULL) AS "activeRelations", (SELECT COUNT(*)::integer FROM assets WHERE ${assignmentColumn} = $1) AS "assignedAssets", (SELECT COUNT(*)::integer FROM area_legal_rights WHERE area_id = $1 AND status IN ('ACTIVE','PENDING') AND (valid_until IS NULL OR valid_until >= CURRENT_DATE)) AS "activeLegalRights", (SELECT COUNT(*)::integer FROM organization_memberships WHERE (parent_organization_id = $1 OR member_organization_id = $1) AND valid_until IS NULL) AS "activeMemberships", (SELECT COUNT(*)::integer FROM area_legal_right_organizations participant JOIN area_legal_rights right_record ON right_record.id=participant.right_id WHERE participant.organization_id=$1 AND participant.valid_until IS NULL AND right_record.status IN ('ACTIVE','PENDING') AND (right_record.valid_until IS NULL OR right_record.valid_until >= CURRENT_DATE)) AS "activeLegalParticipations" `, [asset.id])) as Array<{ activeRelations: number; assignedAssets: number; activeLegalRights: number; activeMemberships: number; activeLegalParticipations: number }>; const activeRelations = Number(usage?.activeRelations ?? 0); const assignedAssets = Number(usage?.assignedAssets ?? 0); const activeLegalRights = Number(usage?.activeLegalRights ?? 0); const activeMemberships = Number(usage?.activeMemberships ?? 0); const activeLegalParticipations = Number(usage?.activeLegalParticipations ?? 0); const blocked = type.operationalRole === AssetTypeOperationalRole.AREA ? activeRelations + assignedAssets + activeLegalRights : activeRelations + assignedAssets + activeMemberships + activeLegalParticipations; if (blocked > 0) { throw new ConflictException({ code: 'OPERATIONAL_ANCHOR_IN_USE', message: type.operationalRole === AssetTypeOperationalRole.AREA ? 'No se puede inactivar el área mientras tenga relaciones, activos operativos o derechos vigentes asociados' : 'No se puede inactivar la organización mientras tenga relaciones, activos, composición UTE o participación legal vigente', activeRelations, assignedAssets, activeLegalRights, activeMemberships, activeLegalParticipations, }); } } private loadDefinitions(manager: EntityManager, typeId: string) { return manager.getRepository(AssetAttributeDefinition).find({ where: { assetTypeId: typeId }, order: { sortOrder: 'ASC', name: 'ASC' }, }); } private async replaceAttributeValues( manager: EntityManager, assetId: string, values: Array<{ definitionId: string; value: unknown }>, userId: string, ): Promise { const repository = manager.getRepository(AssetAttributeValue); await repository.delete({ assetId }); if (values.length) { await repository.save( values.map((entry) => repository.create({ assetId, definitionId: entry.definitionId, value: entry.value, updatedBy: userId, }), ), ); } } private async loadView(manager: EntityManager, id: string): Promise { const rows = (await manager.query( `${this.detailQuery()} WHERE asset.id = $1`, [id], )) as AssetView[]; if (!rows[0]) throw assetNotFound(); return rows[0]; } private listQuery(where: string): string { return ` SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", JSONB_BUILD_OBJECT('id', asset_type.id, 'code', asset_type.code, 'name', asset_type.name) AS type, CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', parent.id, 'code', parent.code, 'name', parent.name ) END AS parent, CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', operational_area.id, 'code', operational_area.code, 'name', operational_area.name ) END AS "operationalArea", CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', operator_company.id, 'code', operator_company.code, 'name', operator_company.name ) END AS "operatorCompany", asset.information_status AS "informationStatus", asset.operational_status AS "operationalStatus", (SELECT COUNT(*)::integer FROM assets child WHERE child.parent_id = asset.id) AS "childrenCount", (current_geometry.asset_id IS NOT NULL) AS "hasGeometry", current_geometry.geometry_type AS "geometryType", (SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL) AS "mediaCount", asset.data_origin AS "dataOrigin", (asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified", asset.current_version AS "currentVersion", asset.updated_at AS "updatedAt" FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN assets parent ON parent.id = asset.parent_id LEFT JOIN assets operational_area ON operational_area.id = asset.operational_area_id LEFT JOIN assets operator_company ON operator_company.id = asset.operator_company_id LEFT JOIN asset_geometries current_geometry ON current_geometry.asset_id = asset.id ${where} ORDER BY asset.name ASC, asset.code ASC `; } private detailQuery(): string { return ` SELECT asset.id, asset.code, asset.name, asset.common_name AS "commonName", asset.description, JSONB_BUILD_OBJECT('id', asset_type.id, 'code', asset_type.code, 'name', asset_type.name) AS type, CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', parent.id, 'code', parent.code, 'name', parent.name ) END AS parent, CASE WHEN operational_area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', operational_area.id, 'code', operational_area.code, 'name', operational_area.name ) END AS "operationalArea", CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( 'id', operator_company.id, 'code', operator_company.code, 'name', operator_company.name ) END AS "operatorCompany", asset.information_status AS "informationStatus", asset.operational_status AS "operationalStatus", (SELECT COUNT(*)::integer FROM assets child WHERE child.parent_id = asset.id) AS "childrenCount", (current_geometry.asset_id IS NOT NULL) AS "hasGeometry", current_geometry.geometry_type AS "geometryType", (SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id = asset.id AND media.deleted_at IS NULL) AS "mediaCount", asset.data_origin AS "dataOrigin", (asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified", asset.current_version AS "currentVersion", asset.created_at AS "createdAt", asset.updated_at AS "updatedAt", asset.created_by AS "createdBy", asset.updated_by AS "updatedBy", COALESCE(( SELECT JSONB_AGG(JSONB_BUILD_OBJECT( 'definitionId', definition.id, 'code', definition.code, 'name', definition.name, 'dataType', definition.data_type, 'isRequired', definition.is_required, 'unit', definition.unit, 'options', definition.options, 'sortOrder', definition.sort_order, 'value', value.value ) ORDER BY definition.sort_order, definition.name) FROM asset_attribute_definitions definition LEFT JOIN asset_attribute_values value ON value.definition_id = definition.id AND value.asset_id = asset.id WHERE definition.asset_type_id = asset.asset_type_id AND definition.is_active = true ), '[]'::jsonb) AS attributes FROM assets asset INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id LEFT JOIN assets parent ON parent.id = asset.parent_id LEFT JOIN assets operational_area ON operational_area.id = asset.operational_area_id LEFT JOIN assets operator_company ON operator_company.id = asset.operator_company_id LEFT JOIN asset_geometries current_geometry ON current_geometry.asset_id = asset.id `; } private auditView(asset: AssetView): Record { return { id: asset.id, code: asset.code, name: asset.name, description: asset.description, type: asset.type, parent: asset.parent, operationalArea: asset.operationalArea, operatorCompany: asset.operatorCompany, informationStatus: asset.informationStatus, operationalStatus: asset.operationalStatus, attributes: asset.attributes.map((attribute) => ({ definitionId: attribute.definitionId, code: attribute.code, value: attribute.value, })), }; } private assetConflict(): ConflictException { return new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS', message: 'Ya existe un activo con ese código', }); } }