From 31a3a207d6f44e1babef6a43ca63347218d4a641 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Tue, 8 Sep 2026 20:01:17 -0300 Subject: [PATCH] fix(inventory): make operator relation temporal and non-owning --- .../asset-operational-relations.service.ts | 104 +++++++++++------- 1 file changed, 65 insertions(+), 39 deletions(-) diff --git a/api-v3/src/asset-master/asset-operational-relations.service.ts b/api-v3/src/asset-master/asset-operational-relations.service.ts index a42909a..09670c0 100644 --- a/api-v3/src/asset-master/asset-operational-relations.service.ts +++ b/api-v3/src/asset-master/asset-operational-relations.service.ts @@ -79,23 +79,15 @@ export class AssetOperationalRelationsService { async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> { await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA); const data = (await this.dataSource.query(` - SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName" - FROM ( - SELECT relation.company_id - FROM area_company_relations relation - WHERE relation.area_id = $1 - AND relation.relation_role = 'OPERATOR' - AND relation.valid_until IS NULL - UNION - SELECT asset.operator_company_id AS company_id - FROM assets asset - WHERE asset.operational_area_id = $1 - AND asset.operator_company_id IS NOT NULL - AND asset.information_status <> 'INACTIVE' - ) linked - INNER JOIN assets company ON company.id = linked.company_id + SELECT DISTINCT company.id, company.code, company.name, + company.common_name AS "commonName", company_type.name AS "typeName" + FROM area_company_relations relation + INNER JOIN assets company ON company.id = relation.company_id INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id - WHERE company.information_status <> 'INACTIVE' + WHERE relation.area_id = $1 + AND relation.relation_role = 'OPERATOR' + AND relation.valid_until IS NULL + AND company.information_status <> 'INACTIVE' ORDER BY company.name, company.code `, [areaId])) as OperationalAssetSummary[]; return { data }; @@ -104,23 +96,15 @@ export class AssetOperationalRelationsService { async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> { await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY); const data = (await this.dataSource.query(` - SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName" - FROM ( - SELECT relation.area_id - FROM area_company_relations relation - WHERE relation.company_id = $1 - AND relation.relation_role = 'OPERATOR' - AND relation.valid_until IS NULL - UNION - SELECT asset.operational_area_id AS area_id - FROM assets asset - WHERE asset.operator_company_id = $1 - AND asset.operational_area_id IS NOT NULL - AND asset.information_status <> 'INACTIVE' - ) linked - INNER JOIN assets area ON area.id = linked.area_id + SELECT DISTINCT area.id, area.code, area.name, + area.common_name AS "commonName", area_type.name AS "typeName" + FROM area_company_relations relation + INNER JOIN assets area ON area.id = relation.area_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id - WHERE area.information_status <> 'INACTIVE' + WHERE relation.company_id = $1 + AND relation.relation_role = 'OPERATOR' + AND relation.valid_until IS NULL + AND area.information_status <> 'INACTIVE' ORDER BY area.name, area.code `, [companyId])) as OperationalAssetSummary[]; return { data }; @@ -157,12 +141,49 @@ export class AssetOperationalRelationsService { const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]); if (!document) throw new BadRequestException({ code: 'SOURCE_DOCUMENT_NOT_FOUND', message: 'El documento fuente no existe' }); } + + if (dto.relationRole === AreaOrganizationRole.OPERATOR) { + const [currentOperator] = (await manager.query(` + SELECT relation.id,company.name AS "companyName" + FROM area_company_relations relation + JOIN assets company ON company.id=relation.company_id + WHERE relation.area_id=$1 + AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL + AND relation.company_id<>$2 + ORDER BY relation.valid_from DESC + LIMIT 1 + FOR UPDATE OF relation + `,[dto.areaId,dto.companyId])) as Array<{id:string;companyName:string}>; + if (currentOperator) { + throw new ConflictException({ + code:'AREA_ACTIVE_OPERATOR_MUST_END_FIRST', + message:`El Área ya tiene una Operadora vigente (${currentOperator.companyName}). Finalizá esa relación antes de registrar la nueva Operadora.`, + }); + } + } + const [row] = (await manager.query(` INSERT INTO area_company_relations ( area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; + + // Compatibility snapshot only. This does not move or re-parent Inventory. + // area_company_relations remains the temporal source of truth. + if (dto.relationRole === AreaOrganizationRole.OPERATOR) { + await manager.query(` + UPDATE assets asset + SET operator_company_id=$2::uuid,updated_at=CURRENT_TIMESTAMP,updated_by=$3::uuid + FROM asset_types type + WHERE type.id=asset.asset_type_id + AND type.operational_role='GENERIC' + AND asset.operational_area_id=$1::uuid + AND asset.operator_company_id IS DISTINCT FROM $2::uuid + `,[dto.areaId,dto.companyId,principal.userId]); + } + const created = await this.loadRelation(manager, row.id); await this.audit.record({ ...administrationAuditContext(principal, request), @@ -170,6 +191,9 @@ export class AssetOperationalRelationsService { entityType: 'area_company_relation', entityId: row.id, afterData: this.auditView(created), + metadata: dto.relationRole === AreaOrganizationRole.OPERATOR + ? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true } + : undefined, }, manager); return created; }); @@ -198,12 +222,9 @@ export class AssetOperationalRelationsService { message: 'La relación ya se encuentra finalizada', }); } - if (before.assignedAssetCount > 0) { - throw new ConflictException({ - code: 'AREA_COMPANY_RELATION_IN_USE', - message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`, - }); - } + + // F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company. + // Ending an operator relation must never be blocked by existing Inventory. await manager.query(` UPDATE area_company_relations SET valid_until = CURRENT_TIMESTAMP, @@ -220,6 +241,10 @@ export class AssetOperationalRelationsService { entityId: id, beforeData: this.auditView(before), afterData: this.auditView(updated), + metadata: { + inventoryHierarchyChanged:false, + retainedCompatibilitySnapshotCount: before.assignedAssetCount, + }, }, manager); return updated; }); @@ -330,7 +355,8 @@ export class AssetOperationalRelationsService { (relation.valid_until IS NULL) AS active, CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset WHERE asset.operational_area_id = relation.area_id - AND asset.operator_company_id = relation.company_id) ELSE 0 END AS "assignedAssetCount" + AND asset.operator_company_id = relation.company_id + AND asset.is_inventory_instance=true) ELSE 0 END AS "assignedAssetCount" FROM area_company_relations relation INNER JOIN assets area ON area.id = relation.area_id INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id