diff --git a/api-v3/src/asset-master/merged-inventory-dossier.service.ts b/api-v3/src/asset-master/merged-inventory-dossier.service.ts new file mode 100644 index 0000000..619cdc5 --- /dev/null +++ b/api-v3/src/asset-master/merged-inventory-dossier.service.ts @@ -0,0 +1,165 @@ +import { Injectable } from '@nestjs/common'; +import { AssetsService } from './assets.service'; +import { InventoryMergeService } from './inventory-merge.service'; + +type LooseRecord = Record; + +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 merges: InventoryMergeService, + ) {} + + async dossier(requestedAssetId: string): Promise> { + 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.assets.dossier(assetId) as LooseRecord; + const identity = dossier.asset as LooseRecord; + return { assetId, identity, dossier }; + })); + + 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 timeline = dedupeById( + dossiers.flatMap(({ identity, dossier }) => + ((dossier.timeline ?? []) as LooseRecord[]).map((event) => ({ + ...event, + meta: { + ...(event.meta ?? {}), + historicalInventory: { + id: identity.id, + code: identity.code, + name: identity.name, + isCanonical: identity.id === canonical.id, + }, + }, + })), + ), + ); + + 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; + + return { + asset: { + id: canonical.id, + code: canonical.code, + name: canonical.name, + commonName: (dossiers.find((item) => item.assetId === canonical.id)?.identity?.commonName ?? null), + }, + 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, + }, + visits, + acts, + findings, + evidence, + communications, + verificationResults, + documents, + inspectionReports, + reports, + media, + versions, + timeline: timeline.slice(0, 1000), + }; + } +}