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

215 lines
7.7 KiB
TypeScript

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<string, unknown>;
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<TemporalAssetDetail> {
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;
// Physical context is reconstructed from the Inventory history, while the
// operator is resolved independently from the temporal Area↔Empresa ledger.
// This keeps historical operator changes from rewriting the Inventory.
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 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"
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 LATERAL (
SELECT relation.company_id
FROM area_company_relations relation
WHERE relation.area_id=history.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= $2
AND (relation.valid_until IS NULL OR relation.valid_until > $2)
ORDER BY relation.valid_from DESC, relation.created_at DESC, relation.id DESC
LIMIT 1
) operator_relation ON true
LEFT JOIN assets operator_company ON operator_company.id=operator_relation.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<string, unknown> | null;
operationalArea: Record<string, unknown> | null;
operatorCompany: Record<string, unknown> | 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"`;
}
}