import { Injectable } from '@nestjs/common'; import { ActivityDossierService } from './activity-dossier.service'; import { AssetsService } from './assets.service'; import { InventoryFunctionService } from './inventory-function.service'; import { InventoryMergeService } from './inventory-merge.service'; type LooseRecord = Record; const MERGEABLE_DOSSIER_TYPES = new Set(['instalacion', 'subinstalacion']); function dedupeById(items: T[]): T[] { const seen = new Set(); const result: T[] = []; for (const item of items) { const id = String(item.id ?? ''); if (!id || seen.has(id)) continue; seen.add(id); result.push(item); } return result; } function sortDesc(items: LooseRecord[], fieldCandidates: string[]): LooseRecord[] { return [...items].sort((a, b) => { const first = fieldCandidates.map((field) => b[field]).find(Boolean) ?? 0; const second = fieldCandidates.map((field) => a[field]).find(Boolean) ?? 0; return new Date(String(first)).getTime() - new Date(String(second)).getTime(); }); } @Injectable() export class MergedInventoryDossierService { constructor( private readonly assets: AssetsService, private readonly activity: ActivityDossierService, private readonly merges: InventoryMergeService, private readonly functions: InventoryFunctionService, ) {} async dossier(requestedAssetId: string): Promise> { const requestedAsset = await this.assets.getById(requestedAssetId); const requestedTypeCode = requestedAsset.type.code.trim().toLowerCase(); // La conciliación/fusión existe sólo para Instalaciones y Subinstalaciones. // Los registros territoriales (Departamento, Área, Yacimiento) deben poder // abrir su Actividad sin depender del subsistema de merge. if (!MERGEABLE_DOSSIER_TYPES.has(requestedTypeCode)) { return await this.activity.dossier(requestedAssetId); } const mergeStatus = await this.merges.status(requestedAssetId) as LooseRecord; const canonical = mergeStatus.canonical as LooseRecord; const requested = mergeStatus.requested as LooseRecord; const aliases = (mergeStatus.aliases ?? []) as LooseRecord[]; const inventoryIds = [canonical.id, ...aliases.map((alias) => alias.id)] .filter(Boolean) .map(String) .filter((value, index, all) => all.indexOf(value) === index); const dossiers = await Promise.all(inventoryIds.map(async (assetId) => { const dossier = await this.activity.dossier(assetId) as LooseRecord; const identity = dossier.asset as LooseRecord; const functionDossier = await this.functions.getForAsset(assetId).catch(() => null) as LooseRecord | null; return { assetId, identity, dossier, functionDossier }; })); const enrich = (entry: LooseRecord, identity: LooseRecord): LooseRecord => ({ ...entry, historicalInventory: { id: identity.id, code: identity.code, name: identity.name, commonName: identity.commonName ?? null, isCanonical: identity.id === canonical.id, }, }); const collect = (key: string): LooseRecord[] => dedupeById( dossiers.flatMap(({ identity, dossier }) => ((dossier[key] ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity)), ), ); const visits = sortDesc(collect('visits'), ['actualStartedAt', 'plannedStartAt', 'createdAt']); const acts = sortDesc(collect('acts'), ['occurredAt', 'createdAt']); const findings = sortDesc(collect('findings'), ['createdAt', 'updatedAt']); const evidence = sortDesc(collect('evidence'), ['capturedAt', 'createdAt']); const communications = sortDesc(collect('communications'), ['occurredAt', 'createdAt']); const verificationResults = sortDesc(collect('verificationResults'), ['verifiedAt', 'resultRecordedAt']); const documents = sortDesc(collect('documents'), ['documentDate', 'linkedAt']); const inspectionReports = sortDesc(collect('inspectionReports'), ['generatedAt']); const media = sortDesc(collect('media'), ['capturedAt', 'createdAt']); const versions = sortDesc(collect('versions'), ['occurredAt']); const functionHistory = sortDesc( dossiers.flatMap(({ identity, functionDossier }) => (((functionDossier?.history ?? []) as LooseRecord[]).map((entry) => enrich(entry, identity))), ), ['validFrom', 'createdAt'], ); const canonicalFunctionDossier = dossiers.find((item) => item.assetId === canonical.id)?.functionDossier ?? null; const currentFunction = canonicalFunctionDossier?.currentFunction ?? null; const timelineSource: LooseRecord[] = dossiers.flatMap(({ identity, dossier }) => ((dossier.timeline ?? []) as LooseRecord[]).map((event): LooseRecord => ({ ...event, meta: { ...(event.meta ?? {}), historicalInventory: { id: identity.id, code: identity.code, name: identity.name, isCanonical: identity.id === canonical.id, }, }, })), ); const timeline: LooseRecord[] = dedupeById(timelineSource); for (const assignment of functionHistory) { timeline.push({ id: `function:${String(assignment.id)}`, kind: 'FUNCTION_CHANGED', occurredAt: assignment.validFrom, title: `Cambio de función · ${String(assignment.functionName)}`, description: assignment.reason ?? null, meta: { functionId: assignment.functionId, functionCode: assignment.functionCode, functionName: assignment.functionName, validFrom: assignment.validFrom, validUntil: assignment.validUntil, changedByUsername: assignment.changedByUsername, historicalInventory: assignment.historicalInventory, }, }); } for (const alias of aliases) { timeline.push({ id: `merge:${String(alias.id)}:${String(alias.mergedAt)}`, kind: 'MERGE', occurredAt: alias.mergedAt, title: `Inventario fusionado · ${String(alias.code)}`, description: alias.reason, meta: { sourceAssetId: alias.id, sourceCode: alias.code, sourceName: alias.name, canonicalAssetId: canonical.id, canonicalCode: canonical.code, canonicalName: canonical.name, historyPreserved: true, }, }); } timeline.sort((a, b) => new Date(String(b.occurredAt ?? 0)).getTime() - new Date(String(a.occurredAt ?? 0)).getTime()); const reports = documents.filter((document) => document.documentType === 'TECHNICAL_REPORT'); const openFindings = findings.filter((finding) => finding.status === 'OPEN').length; const closedFindings = findings.filter((finding) => finding.status === 'CLOSED').length; const warnings = dossiers.flatMap(({ assetId, dossier }) => ((dossier.warnings ?? []) as string[]).map((facet) => `${assetId}:${facet}`), ); return { asset: { id: canonical.id, code: canonical.code, name: canonical.name, commonName: dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null, currentFunction, }, requestedAsset: { id: requested.id, code: requested.code, name: requested.name, }, merge: { requestedWasMerged: requested.id !== canonical.id, canonicalAssetId: canonical.id, aliases, chain: mergeStatus.chain ?? [], aggregatedInventoryIds: inventoryIds, historyPolicy: 'CHRONOLOGICAL_AGGREGATION_WITH_ORIGINAL_IDENTITY', }, 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, functionChanges: functionHistory.length, }, currentFunction, functionHistory, visits, acts, findings, evidence, communications, verificationResults, documents, inspectionReports, reports, media, versions, timeline: timeline.slice(0, 1000), warnings, }; } }