fix(inventory): make operator relation temporal and non-owning
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user