433 lines
17 KiB
TypeScript
433 lines
17 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;
|
|
};
|
|
|
|
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);
|
|
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 [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,
|
|
);
|
|
|
|
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',
|
|
};
|
|
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,
|
|
reason: dto.reason,
|
|
sourceVersionNumber,
|
|
reparentedChildIds,
|
|
historicalReferencesRewritten: false,
|
|
},
|
|
}, 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',
|
|
});
|
|
if (!source.operationalAreaId || !source.operatorCompanyId
|
|
|| source.operationalAreaId !== canonical.operationalAreaId
|
|
|| source.operatorCompanyId !== canonical.operatorCompanyId) {
|
|
throw new BadRequestException({
|
|
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH',
|
|
message: 'Los registros deben pertenecer a la misma Área y Operadora',
|
|
});
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|