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[] }> {
|
async listCompaniesForArea(areaId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||||
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
|
await this.requireAssetRole(this.dataSource.manager, areaId, AssetTypeOperationalRole.AREA);
|
||||||
const data = (await this.dataSource.query(`
|
const data = (await this.dataSource.query(`
|
||||||
SELECT DISTINCT company.id, company.code, company.name, company.common_name AS "commonName", company_type.name AS "typeName"
|
SELECT DISTINCT company.id, company.code, company.name,
|
||||||
FROM (
|
company.common_name AS "commonName", company_type.name AS "typeName"
|
||||||
SELECT relation.company_id
|
|
||||||
FROM area_company_relations relation
|
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 relation.area_id = $1
|
WHERE relation.area_id = $1
|
||||||
AND relation.relation_role = 'OPERATOR'
|
AND relation.relation_role = 'OPERATOR'
|
||||||
AND relation.valid_until IS NULL
|
AND relation.valid_until IS NULL
|
||||||
UNION
|
AND company.information_status <> 'INACTIVE'
|
||||||
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
|
|
||||||
INNER JOIN asset_types company_type ON company_type.id = company.asset_type_id
|
|
||||||
WHERE company.information_status <> 'INACTIVE'
|
|
||||||
ORDER BY company.name, company.code
|
ORDER BY company.name, company.code
|
||||||
`, [areaId])) as OperationalAssetSummary[];
|
`, [areaId])) as OperationalAssetSummary[];
|
||||||
return { data };
|
return { data };
|
||||||
@@ -104,23 +96,15 @@ export class AssetOperationalRelationsService {
|
|||||||
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
async listAreasForCompany(companyId: string): Promise<{ data: OperationalAssetSummary[] }> {
|
||||||
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
|
await this.requireAssetRole(this.dataSource.manager, companyId, AssetTypeOperationalRole.COMPANY);
|
||||||
const data = (await this.dataSource.query(`
|
const data = (await this.dataSource.query(`
|
||||||
SELECT DISTINCT area.id, area.code, area.name, area.common_name AS "commonName", area_type.name AS "typeName"
|
SELECT DISTINCT area.id, area.code, area.name,
|
||||||
FROM (
|
area.common_name AS "commonName", area_type.name AS "typeName"
|
||||||
SELECT relation.area_id
|
|
||||||
FROM area_company_relations relation
|
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 relation.company_id = $1
|
WHERE relation.company_id = $1
|
||||||
AND relation.relation_role = 'OPERATOR'
|
AND relation.relation_role = 'OPERATOR'
|
||||||
AND relation.valid_until IS NULL
|
AND relation.valid_until IS NULL
|
||||||
UNION
|
AND area.information_status <> 'INACTIVE'
|
||||||
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
|
|
||||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
|
||||||
WHERE area.information_status <> 'INACTIVE'
|
|
||||||
ORDER BY area.name, area.code
|
ORDER BY area.name, area.code
|
||||||
`, [companyId])) as OperationalAssetSummary[];
|
`, [companyId])) as OperationalAssetSummary[];
|
||||||
return { data };
|
return { data };
|
||||||
@@ -157,12 +141,49 @@ export class AssetOperationalRelationsService {
|
|||||||
const [document] = await manager.query('SELECT 1 FROM source_documents WHERE id=$1', [dto.sourceDocumentId]);
|
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 (!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(`
|
const [row] = (await manager.query(`
|
||||||
INSERT INTO area_company_relations (
|
INSERT INTO area_company_relations (
|
||||||
area_id, company_id, relation_role, participation_percent, legal_instrument, source_document_id, start_reason, created_by
|
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)
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
|
||||||
RETURNING id
|
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 }>;
|
`, [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);
|
const created = await this.loadRelation(manager, row.id);
|
||||||
await this.audit.record({
|
await this.audit.record({
|
||||||
...administrationAuditContext(principal, request),
|
...administrationAuditContext(principal, request),
|
||||||
@@ -170,6 +191,9 @@ export class AssetOperationalRelationsService {
|
|||||||
entityType: 'area_company_relation',
|
entityType: 'area_company_relation',
|
||||||
entityId: row.id,
|
entityId: row.id,
|
||||||
afterData: this.auditView(created),
|
afterData: this.auditView(created),
|
||||||
|
metadata: dto.relationRole === AreaOrganizationRole.OPERATOR
|
||||||
|
? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true }
|
||||||
|
: undefined,
|
||||||
}, manager);
|
}, manager);
|
||||||
return created;
|
return created;
|
||||||
});
|
});
|
||||||
@@ -198,12 +222,9 @@ export class AssetOperationalRelationsService {
|
|||||||
message: 'La relación ya se encuentra finalizada',
|
message: 'La relación ya se encuentra finalizada',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
if (before.assignedAssetCount > 0) {
|
|
||||||
throw new ConflictException({
|
// F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company.
|
||||||
code: 'AREA_COMPANY_RELATION_IN_USE',
|
// Ending an operator relation must never be blocked by existing Inventory.
|
||||||
message: `No se puede finalizar la relación: ${before.assignedAssetCount} activo(s) todavía dependen de esta combinación`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
await manager.query(`
|
await manager.query(`
|
||||||
UPDATE area_company_relations
|
UPDATE area_company_relations
|
||||||
SET valid_until = CURRENT_TIMESTAMP,
|
SET valid_until = CURRENT_TIMESTAMP,
|
||||||
@@ -220,6 +241,10 @@ export class AssetOperationalRelationsService {
|
|||||||
entityId: id,
|
entityId: id,
|
||||||
beforeData: this.auditView(before),
|
beforeData: this.auditView(before),
|
||||||
afterData: this.auditView(updated),
|
afterData: this.auditView(updated),
|
||||||
|
metadata: {
|
||||||
|
inventoryHierarchyChanged:false,
|
||||||
|
retainedCompatibilitySnapshotCount: before.assignedAssetCount,
|
||||||
|
},
|
||||||
}, manager);
|
}, manager);
|
||||||
return updated;
|
return updated;
|
||||||
});
|
});
|
||||||
@@ -330,7 +355,8 @@ export class AssetOperationalRelationsService {
|
|||||||
(relation.valid_until IS NULL) AS active,
|
(relation.valid_until IS NULL) AS active,
|
||||||
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
|
CASE WHEN relation.relation_role = 'OPERATOR' THEN (SELECT COUNT(*)::integer FROM assets asset
|
||||||
WHERE asset.operational_area_id = relation.area_id
|
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
|
FROM area_company_relations relation
|
||||||
INNER JOIN assets area ON area.id = relation.area_id
|
INNER JOIN assets area ON area.id = relation.area_id
|
||||||
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
INNER JOIN asset_types area_type ON area_type.id = area.asset_type_id
|
||||||
|
|||||||
Reference in New Issue
Block a user