Files
dh-inspeccion-v2/api-v3/src/asset-master/assets.service.ts
T

1721 lines
83 KiB
TypeScript

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.title, 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", visit.title AS "visitTitle"
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", visit.title AS "visitTitle"
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.title AS "visitTitle", 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<Record<string, unknown>>,
Array<Record<string, unknown>>,
Array<Record<string, unknown>>,
Array<Record<string, unknown>>,
Array<Record<string, unknown>>,
Array<Record<string, unknown>>,
Array<Record<string, unknown>>,
Array<Record<string, unknown>>,
Array<Record<string, unknown>>,
Array<Record<string, unknown>>,
];
const timeline: Array<Record<string, unknown>> = [];
const push = (event: Record<string, unknown>) => 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: visit.title,
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<AssetView> {
return this.dataSource.transaction((manager) => this.loadView(manager, id));
}
async create(
dto: CreateAssetDto,
principal: AuthPrincipal,
request: RequestWithContext,
): Promise<AssetView> {
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,'title',visit.title,'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<AssetView> {
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<void> {
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<void> {
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<AssetView> {
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<AssetView> {
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<AssetView> {
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<AssetType> {
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<AssetType> {
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<void> {
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<void> {
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<void> {
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<void> {
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<void> {
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<AssetView> {
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<string, unknown> {
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',
});
}
}