1721 lines
83 KiB
TypeScript
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.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<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: 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<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,'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',
|
|
});
|
|
}
|
|
}
|