import { Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import { AssetVersionChangeType, AuditSource, } from '../database/entities'; import type { ListTemporalAssetsQueryDto, TemporalAtQueryDto, } from './dto/list-temporal-assets-query.dto'; export interface TemporalAssetSummary { id: string; assetId: string; assetCode: string; assetName: string; typeId: string; typeName: string; informationStatus: string; operationalStatus: string; versionNumber: number; changeType: AssetVersionChangeType; changedFields: string[]; occurredAt: Date; effectiveUntil: Date | null; actorUserId: string | null; actorUsername: string | null; source: AuditSource; requestId: string | null; isCurrent: boolean; } export interface TemporalAssetDetail extends TemporalAssetSummary { snapshot: Record; asOf: Date; } function temporalAssetNotFound(): NotFoundException { return new NotFoundException({ code: 'ASSET_NOT_EXISTING_AT_DATE', message: 'El activo no tenía una versión registrada en la fecha solicitada', }); } @Injectable() export class AssetTemporalService { constructor(private readonly dataSource: DataSource) {} async list(query: ListTemporalAssetsQueryDto) { const parameters: unknown[] = [new Date(query.at)]; const conditions: string[] = []; const add = (value: unknown): string => { parameters.push(value); return `$${parameters.length}`; }; if (query.search?.trim()) { const search = add(`%${query.search.trim()}%`); conditions.push(`( version.snapshot->>'code' ILIKE ${search} OR version.snapshot->>'name' ILIKE ${search} )`); } if (query.typeId) { conditions.push(`version.snapshot #>> '{type,id}' = ${add(query.typeId)}`); } if (query.status) { conditions.push(`version.snapshot->>'informationStatus' = ${add(query.status)}`); } const where = conditions.length ? `WHERE ${conditions.join(' AND ')}` : ''; const selected = this.selectedAt('$1'); const [countRow] = (await this.dataSource.query( `WITH selected_version AS (${selected}) SELECT COUNT(*)::integer AS total FROM selected_version version ${where}`, parameters, )) as Array<{ total: number }>; const total = Number(countRow?.total ?? 0); const paginated = [ ...parameters, query.pageSize, (query.page - 1) * query.pageSize, ]; const limit = `$${parameters.length + 1}`; const offset = `$${parameters.length + 2}`; const data = (await this.dataSource.query( `WITH selected_version AS (${selected}) ${this.selectSummary()} FROM selected_version version INNER JOIN assets current_asset ON current_asset.id = version.asset_id LEFT JOIN LATERAL ( SELECT candidate.occurred_at FROM asset_versions candidate WHERE candidate.asset_id = version.asset_id AND candidate.version_number > version.version_number ORDER BY candidate.version_number ASC LIMIT 1 ) next_version ON true ${where} ORDER BY "assetName" ASC, "assetCode" ASC LIMIT ${limit} OFFSET ${offset}`, paginated, )) as TemporalAssetSummary[]; return { data, asOf: new Date(query.at), meta: { page: query.page, pageSize: query.pageSize, total, totalPages: total === 0 ? 0 : Math.ceil(total / query.pageSize), }, }; } async get(assetId: string, query: TemporalAtQueryDto): Promise { const [row] = (await this.dataSource.query( `${this.selectSummary()}, version.snapshot FROM asset_versions version INNER JOIN assets current_asset ON current_asset.id = version.asset_id LEFT JOIN LATERAL ( SELECT candidate.occurred_at FROM asset_versions candidate WHERE candidate.asset_id = version.asset_id AND candidate.version_number > version.version_number ORDER BY candidate.version_number ASC LIMIT 1 ) next_version ON true WHERE version.asset_id = $1 AND version.occurred_at <= $2 ORDER BY version.occurred_at DESC, version.version_number DESC LIMIT 1`, [assetId, new Date(query.at)], )) as TemporalAssetDetail[]; if (!row) throw temporalAssetNotFound(); const asOf = new Date(query.at); row.asOf = asOf; const [context] = (await this.dataSource.query(` SELECT 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" 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 WHERE history.asset_id=$1 AND history.valid_from <= $2 AND (history.valid_until IS NULL OR history.valid_until > $2) ORDER BY history.valid_from DESC LIMIT 1 `, [assetId, asOf])) as Array<{ parent: Record | null; operationalArea: Record | null; operatorCompany: Record | null }>; if (context && row.snapshot) { row.snapshot = { ...row.snapshot, parent: context.parent, operationalArea: context.operationalArea, operatorCompany: context.operatorCompany, }; } return row; } private selectedAt(atParameter: string): string { return `SELECT DISTINCT ON (candidate.asset_id) candidate.* FROM asset_versions candidate WHERE candidate.occurred_at <= ${atParameter} ORDER BY candidate.asset_id, candidate.occurred_at DESC, candidate.version_number DESC`; } private selectSummary(): string { return `SELECT version.id, version.asset_id AS "assetId", version.snapshot->>'code' AS "assetCode", version.snapshot->>'name' AS "assetName", version.snapshot #>> '{type,id}' AS "typeId", version.snapshot #>> '{type,name}' AS "typeName", version.snapshot->>'informationStatus' AS "informationStatus", version.snapshot->>'operationalStatus' AS "operationalStatus", version.version_number AS "versionNumber", version.change_type AS "changeType", version.changed_fields AS "changedFields", version.occurred_at AS "occurredAt", next_version.occurred_at AS "effectiveUntil", version.actor_user_id AS "actorUserId", version.actor_username AS "actorUsername", version.source, version.request_id AS "requestId", (version.version_number = current_asset.current_version) AS "isCurrent"`; } }