* fix(web): simplify inventory administration menu * fix(web): remove legacy imports and function catalog routes * fix(web): remove redundant inspections lifecycle legend * feat(inventory): distinguish physical instances from structural records * fix(dashboard): align inventory and act follow-up metrics * fix(web): align dashboard summary contract * fix(web): clarify dashboard act and report concepts * feat(inventory): mark field-created records as real instances * feat(inventory): map physical instance flag on asset entity * fix(inventory): keep field yacimientos structural * feat(inventory): classify future concrete instances at database level * fix(inventory): count only installation and subinstallation instances * feat(inventory): add authoritative F5 source snapshot * feat(inventory): preload authoritative territory model * feat(inventory): preload authoritative technical catalog * fix(findings): use only authoritative F5 family catalog * fix(inventory): preserve non-hierarchical operator snapshot compatibility * feat(inventory): add inventory-only asset filter * feat(inventory): add inventory-only tree filter * feat(inventory): add inventory browser query contract * feat(inventory): add area-owned inventory browser * feat(inventory): expose area-owned inventory browser * refactor(inventory): remove function catalog and add inventory browser * fix(inventory): make operator relation temporal and non-owning * feat(web): add inventory browser API client * feat(inventory): extend inventory browser filters * feat(inventory): add real inventory list endpoint logic * feat(inventory): expose real inventory list * feat(web): add real inventory list client * refactor(web): make inventory hierarchy area-owned * fix(web): show only real inventory instances * fix(web): style act follow-up tabs and F5 inventory context * fix(web): load F5 flow styles * fix(inventory): apply area-owned operational guard on F5 up * fix(inventory): treat company on asset as non-owning creation snapshot * fix(inventory): resolve field inventory by area hierarchy, not company ownership * fix(inventory): preserve custom catalog and apply authoritative universal findings * fix(inventory): harden authoritative catalog migration checks * fix(inventory): make authoritative territory preload safely reversible * feat(inventory): allow independent company master creation * fix(inventory): make guided creation area-owned and support companies * feat(web): expose independent company master in inventory setup * feat(web): create companies independently from physical inventory hierarchy * fix(inventory): merge by physical area and preserve sealed documents * test(inventory): lock F5 authoritative model and merge invariants * feat(inventory): add family administration DTOs * feat(inventory): administer installation and subinstallation classifications * feat(inventory): expose family classification administration * feat(web): add inventory classification administration API * fix(web): configure finding applicability by inventory classification * fix(web): redefine inventory configuration around hierarchy classifications and columns * chore(release): identify F5 inventory model * chore(release): bump API for F5 inventory model * test(release): expect F5 health metadata * chore(release): align WEB package with F5 inventory cut * chore(release): expose F5 WEB phase * test(dashboard): expect inspector activity and act follow-up metrics * test(dashboard): route F5 summary query mocks explicitly * ci: rehearse all migrations on clean PostGIS before merge * ci: prove F5 migrations revert and reapply cleanly * test(f5): align operational navigation contract * test(f5): align operator lifecycle with area-owned inventory * test(f5): make merge compatibility area-based * test(f5): distinguish literal and normalized yacimiento counts * test(f5): model normalized yacimiento collision explicitly * ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal * ci(f5): bypass irreversible historical reset in clean rehearsal * fix(f5): make territory SQL parameter types explicit * fix(f5): guarantee canonical inventory hierarchy before territory preload * ci(f5): include canonical hierarchy migration in rollback gate * fix(f5): type relation backup markers explicitly * fix(f5): make catalog SQL text parameter types explicit
523 lines
20 KiB
TypeScript
523 lines
20 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ConflictException,
|
|
Injectable,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { DataSource, EntityManager } from 'typeorm';
|
|
import { administrationAuditContext } from '../administration/common/administration-audit';
|
|
import { AuditService } from '../audit/audit.service';
|
|
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
|
|
import {
|
|
AssetVersionChangeType,
|
|
AuditAction,
|
|
} from '../database/entities';
|
|
import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy';
|
|
import type { MergeInventoryAssetDto } from './dto/merge-inventory-asset.dto';
|
|
import { AssetHistoryService } from './asset-history.service';
|
|
|
|
type MergeableAssetRow = {
|
|
id: string;
|
|
code: string;
|
|
name: string;
|
|
typeCode: string;
|
|
parentId: string | null;
|
|
operationalAreaId: string | null;
|
|
operatorCompanyId: string | null;
|
|
inventoryFamilyId: string | null;
|
|
informationStatus: string;
|
|
};
|
|
|
|
type MergeRow = {
|
|
id: string;
|
|
sourceAssetId: string;
|
|
canonicalAssetId: string;
|
|
reason: string;
|
|
mergedAt: Date;
|
|
mergedBy: string | null;
|
|
source: string;
|
|
requestId: string | null;
|
|
};
|
|
|
|
type DocumentInvariantRow = {
|
|
actId: string;
|
|
actStatus: string;
|
|
lockedSha256: string | null;
|
|
closureSha256: string | null;
|
|
sealedAt: Date | null;
|
|
actVersion: number;
|
|
reportId: string | null;
|
|
reportStatus: string | null;
|
|
reportActClosureSha256: string | null;
|
|
reportFrozenSha256: string | null;
|
|
gedoPdfSha256: string | null;
|
|
wordSha256: string | null;
|
|
reportRevision: number | null;
|
|
};
|
|
|
|
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
|
|
|
|
@Injectable()
|
|
export class InventoryMergeService {
|
|
constructor(
|
|
private readonly dataSource: DataSource,
|
|
private readonly audit: AuditService,
|
|
private readonly history: AssetHistoryService,
|
|
) {}
|
|
|
|
async status(assetId: string) {
|
|
const requested = await this.loadAsset(this.dataSource.manager, assetId, false);
|
|
const canonicalId = await this.resolveCanonicalId(this.dataSource.manager, assetId);
|
|
const canonical = canonicalId === assetId
|
|
? requested
|
|
: await this.loadAsset(this.dataSource.manager, canonicalId, false);
|
|
const chain = await this.mergeChain(assetId);
|
|
const aliases = await this.aliasesFor(canonicalId);
|
|
return {
|
|
requested,
|
|
isMerged: canonicalId !== assetId,
|
|
canonical,
|
|
chain,
|
|
aliases,
|
|
};
|
|
}
|
|
|
|
async merge(
|
|
sourceAssetId: string,
|
|
dto: MergeInventoryAssetDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
) {
|
|
return this.dataSource.transaction((manager) =>
|
|
this.mergeInTransaction(manager, sourceAssetId, dto, principal, request),
|
|
);
|
|
}
|
|
|
|
async mergeFromVisit(
|
|
visitId: string,
|
|
sourceAssetId: string,
|
|
dto: MergeInventoryAssetDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
) {
|
|
assertMobileInspector(principal);
|
|
const [gate] = (await this.dataSource.query(`
|
|
SELECT
|
|
visit.status,
|
|
EXISTS (
|
|
SELECT 1 FROM inspection_visit_members member
|
|
WHERE member.visit_id=visit.id
|
|
AND member.user_id=$3::uuid
|
|
AND member.included=true
|
|
) OR visit.lead_inspector_user_id=$3::uuid AS assigned,
|
|
EXISTS (
|
|
SELECT 1 FROM asset_field_discoveries discovery
|
|
WHERE discovery.visit_id=visit.id
|
|
AND discovery.asset_id=$2::uuid
|
|
) AS "createdInVisit"
|
|
FROM inspection_visits visit
|
|
WHERE visit.id=$1::uuid
|
|
`, [visitId, sourceAssetId, principal.userId])) as Array<{
|
|
status: string;
|
|
assigned: boolean;
|
|
createdInVisit: boolean;
|
|
}>;
|
|
if (!gate) throw new NotFoundException({ code: 'INSPECTION_VISIT_NOT_FOUND', message: 'Inspección no encontrada' });
|
|
if (gate.status !== 'IN_PROGRESS') throw new ConflictException({
|
|
code: 'FIELD_MERGE_VISIT_NOT_IN_PROGRESS',
|
|
message: 'Sólo se puede conciliar Inventario mientras la inspección está en curso',
|
|
});
|
|
if (!gate.assigned) throw new ConflictException({
|
|
code: 'FIELD_MERGE_INSPECTOR_NOT_ASSIGNED',
|
|
message: 'El inspector no está asignado a esta inspección',
|
|
});
|
|
if (!gate.createdInVisit) throw new ConflictException({
|
|
code: 'FIELD_MERGE_SOURCE_MUST_BE_DISCOVERY',
|
|
message: 'Desde la APK sólo se puede fusionar un alta nacida en esta inspección',
|
|
});
|
|
return this.merge(sourceAssetId, dto, principal, request);
|
|
}
|
|
|
|
private async mergeInTransaction(
|
|
manager: EntityManager,
|
|
sourceAssetId: string,
|
|
dto: MergeInventoryAssetDto,
|
|
principal: AuthPrincipal,
|
|
request: RequestWithContext,
|
|
) {
|
|
const existing = await this.currentMerge(manager, sourceAssetId);
|
|
if (existing) throw new ConflictException({
|
|
code: 'INVENTORY_ALREADY_MERGED',
|
|
message: 'Este registro ya fue fusionado',
|
|
canonicalAssetId: existing.canonicalAssetId,
|
|
});
|
|
|
|
const canonicalAssetId = await this.resolveCanonicalId(manager, dto.canonicalAssetId);
|
|
if (canonicalAssetId === sourceAssetId) throw new BadRequestException({
|
|
code: 'INVENTORY_MERGE_CYCLE',
|
|
message: 'La fusión generaría un ciclo entre registros',
|
|
});
|
|
|
|
const ids = [sourceAssetId, canonicalAssetId].sort();
|
|
const locked = (await manager.query(`
|
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
|
asset.parent_id AS "parentId",
|
|
asset.operational_area_id AS "operationalAreaId",
|
|
asset.operator_company_id AS "operatorCompanyId",
|
|
asset.inventory_family_id AS "inventoryFamilyId",
|
|
asset.information_status AS "informationStatus"
|
|
FROM assets asset
|
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
WHERE asset.id=ANY($1::uuid[])
|
|
ORDER BY asset.id
|
|
FOR UPDATE OF asset
|
|
`, [ids])) as MergeableAssetRow[];
|
|
const source = locked.find((row) => row.id === sourceAssetId);
|
|
const canonical = locked.find((row) => row.id === canonicalAssetId);
|
|
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
|
|
|
this.validatePair(source, canonical);
|
|
|
|
// Empresa is temporal inspection context, not physical ownership. A merge is
|
|
// valid when both records resolve to the same Area in the physical hierarchy,
|
|
// even if their historical operator snapshots differ.
|
|
const [sourceAreaId, canonicalAreaId] = await Promise.all([
|
|
this.resolvePhysicalAreaId(manager, source.id),
|
|
this.resolvePhysicalAreaId(manager, canonical.id),
|
|
]);
|
|
if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({
|
|
code: 'INVENTORY_MERGE_AREA_MISMATCH',
|
|
message: 'Los duplicados deben pertenecer a la misma Área física',
|
|
});
|
|
|
|
const sourceParentCanonical = source.parentId
|
|
? await this.resolveCanonicalId(manager, source.parentId)
|
|
: null;
|
|
const targetParentCanonical = canonical.parentId
|
|
? await this.resolveCanonicalId(manager, canonical.parentId)
|
|
: null;
|
|
if (sourceParentCanonical !== targetParentCanonical) throw new BadRequestException({
|
|
code: 'INVENTORY_MERGE_PARENT_MISMATCH',
|
|
message: 'Los duplicados deben pertenecer al mismo padre estructural (considerando fusiones previas)',
|
|
});
|
|
|
|
if (source.typeCode.toLowerCase() === 'instalacion') {
|
|
const invalidChildren = await manager.query(`
|
|
SELECT child.id,child.code,child.name
|
|
FROM assets child
|
|
WHERE child.parent_id=$1::uuid
|
|
AND child.information_status<>'INACTIVE'
|
|
AND (
|
|
child.inventory_family_id IS NULL
|
|
OR $2::uuid IS NULL
|
|
OR NOT EXISTS (
|
|
SELECT 1 FROM inventory_family_parent_rules rule
|
|
WHERE rule.child_family_id=child.inventory_family_id
|
|
AND rule.parent_family_id=$2::uuid
|
|
)
|
|
)
|
|
ORDER BY child.name,child.code
|
|
LIMIT 20
|
|
`, [source.id, canonical.inventoryFamilyId]) as Array<{ id: string; code: string; name: string }>;
|
|
if (invalidChildren.length > 0) throw new ConflictException({
|
|
code: 'INVENTORY_MERGE_CHILD_FAMILY_CONFLICT',
|
|
message: 'Hay Subinstalaciones que no son compatibles con la familia de la Instalación canónica',
|
|
data: invalidChildren,
|
|
});
|
|
}
|
|
|
|
const affectedAssetIds = [source.id, canonical.id];
|
|
const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds);
|
|
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
|
|
this.snapshot(manager, source.id),
|
|
this.snapshot(manager, canonical.id),
|
|
]);
|
|
const sourceLabel = principal.transport === 'bearer' ? 'ANDROID' : 'WEB';
|
|
const [mergeRecord] = (await manager.query(`
|
|
INSERT INTO asset_merges (
|
|
source_asset_id,canonical_asset_id,reason,merged_by,source,request_id,
|
|
source_snapshot,canonical_snapshot
|
|
) VALUES ($1::uuid,$2::uuid,$3::text,$4::uuid,$5::varchar,$6::varchar,$7::jsonb,$8::jsonb)
|
|
RETURNING id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
|
|
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
|
|
`, [
|
|
source.id,
|
|
canonical.id,
|
|
dto.reason,
|
|
principal.userId,
|
|
sourceLabel,
|
|
request.requestId,
|
|
sourceSnapshot,
|
|
canonicalSnapshot,
|
|
])) as MergeRow[];
|
|
|
|
const children = (await manager.query(`
|
|
SELECT id FROM assets
|
|
WHERE parent_id=$1::uuid AND information_status<>'INACTIVE'
|
|
ORDER BY id FOR UPDATE
|
|
`, [source.id])) as Array<{ id: string }>;
|
|
const reparentedChildIds: string[] = [];
|
|
for (const child of children) {
|
|
await manager.query(`
|
|
UPDATE asset_context_history
|
|
SET valid_until=CURRENT_TIMESTAMP,
|
|
end_reason=$2::text,
|
|
ended_by=$3::uuid,
|
|
ended_at=CURRENT_TIMESTAMP
|
|
WHERE asset_id=$1::uuid AND valid_until IS NULL
|
|
`, [child.id, `Reparentado por fusión cronológica ${source.code} → ${canonical.code}`, principal.userId]);
|
|
await manager.query(`
|
|
UPDATE assets
|
|
SET parent_id=$2::uuid,updated_by=$3::uuid,updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=$1::uuid
|
|
`, [child.id, canonical.id, principal.userId]);
|
|
const versionNumber = await this.history.capture(
|
|
manager,
|
|
child.id,
|
|
AssetVersionChangeType.UPDATED,
|
|
principal,
|
|
request,
|
|
);
|
|
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
|
|
)
|
|
SELECT id,parent_id,operational_area_id,operator_company_id,CURRENT_TIMESTAMP,
|
|
$2::text,$3::integer,$4::varchar,$5::varchar,$6::uuid
|
|
FROM assets WHERE id=$1::uuid
|
|
`, [
|
|
child.id,
|
|
`Reparentado por fusión cronológica ${source.code} → ${canonical.code}`,
|
|
versionNumber,
|
|
sourceLabel,
|
|
request.requestId,
|
|
principal.userId,
|
|
]);
|
|
reparentedChildIds.push(child.id);
|
|
}
|
|
|
|
await manager.query(`
|
|
UPDATE assets
|
|
SET information_status='INACTIVE',updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP
|
|
WHERE id=$1::uuid
|
|
`, [source.id, principal.userId]);
|
|
const sourceVersionNumber = await this.history.capture(
|
|
manager,
|
|
source.id,
|
|
AssetVersionChangeType.STATUS_CHANGED,
|
|
principal,
|
|
request,
|
|
);
|
|
|
|
// No historical Acta/Finding/Informe foreign key is rewritten by a merge.
|
|
// Verify that legal/documentary fingerprints are byte-for-byte unchanged
|
|
// before committing the transaction; otherwise rollback the entire merge.
|
|
const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds);
|
|
if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) {
|
|
throw new ConflictException({
|
|
code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN',
|
|
message: 'La fusión intentó alterar la huella documental histórica y fue revertida',
|
|
});
|
|
}
|
|
|
|
const result = {
|
|
merge: mergeRecord,
|
|
source: { id: source.id, code: source.code, name: source.name },
|
|
canonical: { id: canonical.id, code: canonical.code, name: canonical.name },
|
|
sourceVersionNumber,
|
|
reparentedChildIds,
|
|
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
|
|
documentaryInvariantsVerified: true,
|
|
};
|
|
await this.audit.record({
|
|
...administrationAuditContext(principal, request),
|
|
action: AuditAction.ASSET_UPDATED,
|
|
entityType: 'asset_merge',
|
|
entityId: mergeRecord.id,
|
|
beforeData: sourceSnapshot,
|
|
afterData: canonicalSnapshot,
|
|
metadata: {
|
|
operation: 'CHRONOLOGICAL_MERGE',
|
|
sourceAssetId: source.id,
|
|
canonicalAssetId: canonical.id,
|
|
physicalAreaId: sourceAreaId,
|
|
reason: dto.reason,
|
|
sourceVersionNumber,
|
|
reparentedChildIds,
|
|
historicalReferencesRewritten: false,
|
|
documentaryInvariantsVerified: true,
|
|
},
|
|
}, manager);
|
|
return result;
|
|
}
|
|
|
|
private validatePair(source: MergeableAssetRow, canonical: MergeableAssetRow): void {
|
|
const sourceType = source.typeCode.toLowerCase();
|
|
const targetType = canonical.typeCode.toLowerCase();
|
|
if (!MERGEABLE_TYPES.has(sourceType) || sourceType !== targetType) throw new BadRequestException({
|
|
code: 'INVENTORY_MERGE_TYPE_INVALID',
|
|
message: 'Sólo se pueden fusionar Instalaciones entre sí o Subinstalaciones entre sí',
|
|
});
|
|
if (source.informationStatus === 'INACTIVE') throw new ConflictException({
|
|
code: 'INVENTORY_MERGE_SOURCE_INACTIVE',
|
|
message: 'El registro de origen ya está inactivo',
|
|
});
|
|
if (canonical.informationStatus === 'INACTIVE') throw new ConflictException({
|
|
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
|
|
message: 'El registro canónico no puede estar inactivo',
|
|
});
|
|
}
|
|
|
|
private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
|
|
const rows = (await manager.query(`
|
|
WITH RECURSIVE lineage AS (
|
|
SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth
|
|
FROM assets asset WHERE asset.id=$1::uuid
|
|
UNION ALL
|
|
SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1
|
|
FROM assets parent
|
|
JOIN lineage ON lineage.parent_id=parent.id
|
|
WHERE lineage.depth<32
|
|
)
|
|
SELECT lineage.id
|
|
FROM lineage
|
|
JOIN asset_types type ON type.id=lineage.asset_type_id
|
|
WHERE type.operational_role='AREA'
|
|
ORDER BY lineage.depth
|
|
LIMIT 1
|
|
`, [assetId])) as Array<{ id: string }>;
|
|
return rows[0]?.id ?? null;
|
|
}
|
|
|
|
private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise<DocumentInvariantRow[]> {
|
|
return (await manager.query(`
|
|
WITH affected_acts AS (
|
|
SELECT DISTINCT act.id
|
|
FROM inspection_acts act
|
|
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=ANY($1::uuid[])
|
|
OR finding.asset_id=ANY($1::uuid[])
|
|
)
|
|
SELECT
|
|
act.id AS "actId",
|
|
act.status AS "actStatus",
|
|
act.locked_sha256 AS "lockedSha256",
|
|
act.closure_sha256 AS "closureSha256",
|
|
act.sealed_at AS "sealedAt",
|
|
act.current_version AS "actVersion",
|
|
report.id AS "reportId",
|
|
report.status AS "reportStatus",
|
|
report.act_closure_sha256 AS "reportActClosureSha256",
|
|
report.frozen_sha256 AS "reportFrozenSha256",
|
|
report.gedo_pdf_sha256 AS "gedoPdfSha256",
|
|
report.word_sha256 AS "wordSha256",
|
|
report.current_revision_number AS "reportRevision"
|
|
FROM affected_acts affected
|
|
JOIN inspection_acts act ON act.id=affected.id
|
|
LEFT JOIN inspection_reports report ON report.act_id=act.id
|
|
ORDER BY act.id,report.id NULLS FIRST
|
|
`, [assetIds])) as DocumentInvariantRow[];
|
|
}
|
|
|
|
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
|
|
const lockSql = lock ? 'FOR UPDATE OF asset' : '';
|
|
const rows = (await manager.query(`
|
|
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
|
|
asset.parent_id AS "parentId",
|
|
asset.operational_area_id AS "operationalAreaId",
|
|
asset.operator_company_id AS "operatorCompanyId",
|
|
asset.inventory_family_id AS "inventoryFamilyId",
|
|
asset.information_status AS "informationStatus"
|
|
FROM assets asset JOIN asset_types type ON type.id=asset.asset_type_id
|
|
WHERE asset.id=$1::uuid ${lockSql}
|
|
`, [id])) as MergeableAssetRow[];
|
|
if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
|
return rows[0];
|
|
}
|
|
|
|
private async currentMerge(manager: EntityManager, sourceAssetId: string): Promise<MergeRow | null> {
|
|
const rows = (await manager.query(`
|
|
SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
|
|
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
|
|
FROM asset_merges WHERE source_asset_id=$1::uuid
|
|
`, [sourceAssetId])) as MergeRow[];
|
|
return rows[0] ?? null;
|
|
}
|
|
|
|
private async resolveCanonicalId(manager: EntityManager, assetId: string): Promise<string> {
|
|
const rows = (await manager.query(`
|
|
WITH RECURSIVE chain AS (
|
|
SELECT source_asset_id,canonical_asset_id,1 AS depth
|
|
FROM asset_merges WHERE source_asset_id=$1::uuid
|
|
UNION ALL
|
|
SELECT merge_record.source_asset_id,merge_record.canonical_asset_id,chain.depth+1
|
|
FROM asset_merges merge_record
|
|
JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id
|
|
WHERE chain.depth<32
|
|
)
|
|
SELECT canonical_asset_id AS id FROM chain ORDER BY depth DESC LIMIT 1
|
|
`, [assetId])) as Array<{ id: string }>;
|
|
return rows[0]?.id ?? assetId;
|
|
}
|
|
|
|
private async mergeChain(assetId: string): Promise<MergeRow[]> {
|
|
return (await this.dataSource.query(`
|
|
WITH RECURSIVE chain AS (
|
|
SELECT id,source_asset_id,canonical_asset_id,reason,merged_at,merged_by,source,request_id,1 AS depth
|
|
FROM asset_merges WHERE source_asset_id=$1::uuid
|
|
UNION ALL
|
|
SELECT merge_record.id,merge_record.source_asset_id,merge_record.canonical_asset_id,
|
|
merge_record.reason,merge_record.merged_at,merge_record.merged_by,merge_record.source,merge_record.request_id,
|
|
chain.depth+1
|
|
FROM asset_merges merge_record
|
|
JOIN chain ON merge_record.source_asset_id=chain.canonical_asset_id
|
|
WHERE chain.depth<32
|
|
)
|
|
SELECT id,source_asset_id AS "sourceAssetId",canonical_asset_id AS "canonicalAssetId",
|
|
reason,merged_at AS "mergedAt",merged_by AS "mergedBy",source,request_id AS "requestId"
|
|
FROM chain ORDER BY depth
|
|
`, [assetId])) as MergeRow[];
|
|
}
|
|
|
|
private async aliasesFor(canonicalAssetId: string) {
|
|
return this.dataSource.query(`
|
|
WITH RECURSIVE aliases AS (
|
|
SELECT source_asset_id,canonical_asset_id,reason,merged_at,1 AS depth
|
|
FROM asset_merges WHERE canonical_asset_id=$1::uuid
|
|
UNION ALL
|
|
SELECT merge_record.source_asset_id,merge_record.canonical_asset_id,
|
|
merge_record.reason,merge_record.merged_at,aliases.depth+1
|
|
FROM asset_merges merge_record
|
|
JOIN aliases ON merge_record.canonical_asset_id=aliases.source_asset_id
|
|
WHERE aliases.depth<32
|
|
)
|
|
SELECT alias.source_asset_id AS id,asset.code,asset.name,alias.reason,
|
|
alias.merged_at AS "mergedAt",alias.depth
|
|
FROM aliases alias
|
|
JOIN assets asset ON asset.id=alias.source_asset_id
|
|
ORDER BY alias.merged_at,alias.depth
|
|
`, [canonicalAssetId]);
|
|
}
|
|
|
|
private async snapshot(manager: EntityManager, assetId: string): Promise<Record<string, unknown>> {
|
|
const rows = (await manager.query(`
|
|
SELECT TO_JSONB(asset) || JSONB_BUILD_OBJECT(
|
|
'typeCode',type.code,
|
|
'typeName',type.name,
|
|
'inventoryFamily',CASE WHEN family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
|
|
'id',family.id,'code',family.code,'name',family.name,'level',family.level
|
|
) END
|
|
) AS snapshot
|
|
FROM assets asset
|
|
JOIN asset_types type ON type.id=asset.asset_type_id
|
|
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
|
|
WHERE asset.id=$1::uuid
|
|
`, [assetId])) as Array<{ snapshot: Record<string, unknown> }>;
|
|
if (!rows[0]) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
|
|
return rows[0].snapshot;
|
|
}
|
|
}
|