diff --git a/api-v3/src/asset-master/asset-master.module.ts b/api-v3/src/asset-master/asset-master.module.ts index 4742fb9..fba816d 100644 --- a/api-v3/src/asset-master/asset-master.module.ts +++ b/api-v3/src/asset-master/asset-master.module.ts @@ -24,6 +24,7 @@ import { AssetRegistryService } from './asset-registry.service'; import { FieldDiscoveryInspectionLinkService } from '../inspection-operations/field-discovery-inspection-link.service'; import { InventoryStructureController } from './inventory-structure.controller'; import { InventoryStructureService } from './inventory-structure.service'; +import { SimpleInventoryStructureService } from './simple-inventory-structure.service'; import { InventoryFamilyCatalogController } from './inventory-family-catalog.controller'; import { InventoryFamilyCatalogService } from './inventory-family-catalog.service'; import { InventoryTechnicalValuesController } from './inventory-technical-values.controller'; @@ -59,6 +60,7 @@ import { InventoryBrowserService } from './inventory-browser.service'; AssetTypesService, AssetsService, InventoryStructureService, + SimpleInventoryStructureService, InventoryFamilyCatalogService, InventoryTechnicalValuesService, InventoryFunctionService, 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 ae547aa..87400ca 100644 --- a/api-v3/src/asset-master/asset-operational-relations.service.ts +++ b/api-v3/src/asset-master/asset-operational-relations.service.ts @@ -142,27 +142,8 @@ export class AssetOperationalRelationsService { 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.`, - }); - } - } - + // F7: un Área puede estar explotada por varias Empresas simultáneamente. + // La unicidad válida es Área + Empresa + rol activo; no existe una única Operadora por Área. 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 @@ -170,9 +151,6 @@ export class AssetOperationalRelationsService { 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 }>; - // F5/F6: changing the Area operator is a temporal relation event only. - // Existing Inventory keeps its creation/historical operator snapshot and - // physical hierarchy unchanged. Runtime ownership must resolve this row. const created = await this.loadRelation(manager, row.id); await this.audit.record({ ...administrationAuditContext(principal, request), @@ -181,7 +159,7 @@ export class AssetOperationalRelationsService { entityId: row.id, afterData: this.auditView(created), metadata: dto.relationRole === AreaOrganizationRole.OPERATOR - ? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true } + ? { inventoryHierarchyChanged:false, multipleAreaOperatorsAllowed:true } : undefined, }, manager); return created; @@ -212,7 +190,6 @@ export class AssetOperationalRelationsService { }); } - // F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company. // Ending an operator relation never moves, rewrites or blocks existing Inventory. await manager.query(` UPDATE area_company_relations @@ -232,6 +209,7 @@ export class AssetOperationalRelationsService { afterData: this.auditView(updated), metadata: { inventoryHierarchyChanged:false, + operatorSnapshotPreserved:true, retainedCompatibilitySnapshotCount: before.assignedAssetCount, }, }, manager); diff --git a/api-v3/src/asset-master/inventory-structure.controller.ts b/api-v3/src/asset-master/inventory-structure.controller.ts index 41ca083..104e12a 100644 --- a/api-v3/src/asset-master/inventory-structure.controller.ts +++ b/api-v3/src/asset-master/inventory-structure.controller.ts @@ -4,10 +4,14 @@ import { RequirePermissions } from '../authorization/decorators/require-permissi import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; import { CreateInventoryStructureDto } from './dto/create-inventory-structure.dto'; import { InventoryStructureService } from './inventory-structure.service'; +import { SimpleInventoryStructureService } from './simple-inventory-structure.service'; @Controller('inventory-structure') export class InventoryStructureController { - constructor(private readonly inventoryStructure: InventoryStructureService) {} + constructor( + private readonly inventoryStructure: InventoryStructureService, + private readonly simpleInventoryStructure: SimpleInventoryStructureService, + ) {} @Get() @RequirePermissions('assets.read') @@ -24,6 +28,17 @@ export class InventoryStructureController { return this.inventoryStructure.parents(kind, search); } + @Post('simple') + @RequirePermissions('assets.create') + createSimple( + @Body() dto: CreateInventoryStructureDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + return this.simpleInventoryStructure.create(dto, principal, request); + } + + // Compatibilidad temporal con el alta anterior. La WEB F7 utiliza /simple. @Post() @RequirePermissions('assets.create') create( diff --git a/api-v3/src/asset-master/simple-inventory-structure.service.ts b/api-v3/src/asset-master/simple-inventory-structure.service.ts new file mode 100644 index 0000000..78604e1 --- /dev/null +++ b/api-v3/src/asset-master/simple-inventory-structure.service.ts @@ -0,0 +1,323 @@ +import { randomUUID } from 'node:crypto'; +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { DataSource, EntityManager } from 'typeorm'; +import { administrationAuditContext, isUniqueViolation } from '../administration/common/administration-audit'; +import { AuditService } from '../audit/audit.service'; +import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { + AssetDataOrigin, + AssetInformationStatus, + AssetOperationalStatus, + AssetVersionChangeType, + AuditAction, +} from '../database/entities'; +import type { CreateInventoryStructureDto, InventoryStructureKind } from './dto/create-inventory-structure.dto'; +import { AssetHistoryService } from './asset-history.service'; + +type StructureTypeRow = { id: string; code: string; name: string }; +type ParentRow = { + id: string; + code: string; + name: string; + typeCode: string; + inventoryFamilyId: string | null; + operationalAreaId: string | null; + operatorCompanyId: string | null; +}; +type FamilyRow = { + id: string; + code: string; + name: string; + level: 'INSTALLATION' | 'SUBINSTALLATION'; +}; + +const TYPE_CODE_BY_KIND: Record, string> = { + DEPARTAMENTO: 'departamento', + AREA: 'area', + YACIMIENTO: 'yacimiento', + INSTALACION: 'instalacion', + SUBINSTALACION: 'subinstalacion', +}; + +const PARENT_TYPE_BY_KIND: Record = { + EMPRESA: null, + DEPARTAMENTO: null, + AREA: 'departamento', + YACIMIENTO: 'area', + INSTALACION: 'yacimiento', + SUBINSTALACION: 'instalacion', +}; + +@Injectable() +export class SimpleInventoryStructureService { + constructor( + private readonly dataSource: DataSource, + private readonly audit: AuditService, + private readonly history: AssetHistoryService, + ) {} + + async create( + dto: CreateInventoryStructureDto, + principal: AuthPrincipal, + request: RequestWithContext, + ) { + try { + return await this.dataSource.transaction(async (manager) => { + const type = await this.requireStructureType(manager, dto.kind); + const parent = await this.requireParent(manager, dto.kind, dto.parentId ?? null); + const family = await this.requireFamily(manager, dto.kind, dto.familyId ?? null, parent); + + let operationalAreaId: string | null = null; + let operatorCompanyId: string | null = null; + + if (dto.kind === 'YACIMIENTO') { + if (!parent) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_AREA_REQUIRED', message: 'Seleccioná el Área del Yacimiento' }); + if (!dto.operatorCompanyId) throw new BadRequestException({ code: 'INVENTORY_YACIMIENTO_COMPANY_REQUIRED', message: 'Seleccioná la Empresa operadora del Yacimiento' }); + await this.requireAreaCompanyRelation(manager, parent.id, dto.operatorCompanyId); + operationalAreaId = parent.id; + operatorCompanyId = dto.operatorCompanyId; + } else if (dto.kind === 'INSTALACION' || dto.kind === 'SUBINSTALACION') { + operationalAreaId = parent?.operationalAreaId ?? null; + operatorCompanyId = parent?.operatorCompanyId ?? null; + if (!operationalAreaId || !operatorCompanyId) { + throw new ConflictException({ + code: 'INVENTORY_YACIMIENTO_CONTEXT_MISSING', + message: 'El Yacimiento de origen debe tener Área y Empresa operadora válidas', + }); + } + } else if (dto.operatorCompanyId || dto.concessionTypeId) { + throw new BadRequestException({ + code: 'INVENTORY_SIMPLE_CONTEXT_NOT_ALLOWED', + message: 'Empresa operadora sólo corresponde al Yacimiento', + }); + } + + const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name); + const rows = (await manager.query(` + INSERT INTO assets ( + asset_type_id,parent_id,operational_area_id,operator_company_id,concession_type_id,inventory_family_id, + code,name,common_name,description,information_status,operational_status, + data_origin,source_name,source_reference,source_notes,created_by,updated_by,provenance_updated_by + ) VALUES ( + $1::uuid,$2::uuid,$3::uuid,$4::uuid,NULL,$5::uuid, + $6::varchar,$7::varchar,NULL,NULL,$8::asset_information_status,$9::asset_operational_status, + $10::varchar,$11::varchar,$12::varchar,$13::text,$14::uuid,$14::uuid,$14::uuid + ) RETURNING id + `, [ + type.id, + parent?.id ?? null, + operationalAreaId, + operatorCompanyId, + family?.id ?? null, + generatedCode, + dto.name, + AssetInformationStatus.DRAFT, + AssetOperationalStatus.UNKNOWN, + AssetDataOrigin.MANUAL, + dto.kind === 'EMPRESA' ? 'Maestro manual de Empresas' : 'Alta simple de Inventario', + `inventory-simple:${dto.kind.toLowerCase()}`, + family ? `Tipo: ${family.name}` : null, + principal.userId, + ])) as Array<{ id: string }>; + const id = rows[0]?.id; + if (!id) throw new Error('No se pudo crear el registro'); + + if (dto.kind === 'EMPRESA') { + await manager.query(` + INSERT INTO organization_profiles(asset_id,organization_kind,legal_name,updated_by) + VALUES ($1::uuid,'COMPANY',$2,$3::uuid) + ON CONFLICT (asset_id) DO UPDATE + SET legal_name=EXCLUDED.legal_name,updated_by=EXCLUDED.updated_by,updated_at=CURRENT_TIMESTAMP + `, [id, dto.name, principal.userId]); + } + + const versionNumber = await this.history.capture( + manager, + id, + AssetVersionChangeType.CREATED, + 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 + ) VALUES ($1,$2,$3,$4,CURRENT_TIMESTAMP,$5,$6,'WEB',$7,$8) + `, [ + id, + parent?.id ?? null, + operationalAreaId, + operatorCompanyId, + dto.kind === 'YACIMIENTO' + ? 'Alta de Yacimiento con Empresa operadora vinculada al Área' + : 'Alta simple de Inventario', + versionNumber, + request.requestId, + principal.userId, + ]); + + const created = await this.loadView(manager, id); + await this.audit.record({ + ...administrationAuditContext(principal, request), + action: AuditAction.ASSET_CREATED, + entityType: 'asset', + entityId: id, + afterData: created as Record, + metadata: { + versionNumber, + inventoryStructureKind: dto.kind, + inventoryFamilyId: family?.id ?? null, + inventoryFamilyCode: family?.code ?? null, + operatorCompanyId, + simpleInventoryFlow: true, + }, + }, manager); + return created; + }); + } catch (error) { + if (isUniqueViolation(error)) { + throw new ConflictException({ code: 'ASSET_CODE_ALREADY_EXISTS', message: 'Ya existe un registro con ese código' }); + } + throw error; + } + } + + private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise { + const sql = kind === 'EMPRESA' + ? `SELECT id,code,name FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1` + : `SELECT id,code,name FROM asset_types WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1`; + const params = kind === 'EMPRESA' ? [] : [TYPE_CODE_BY_KIND[kind as Exclude]]; + const rows = (await manager.query(sql, params)) as StructureTypeRow[]; + if (!rows[0]) throw new ConflictException({ code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED', message: `El nivel ${kind} no está configurado` }); + return rows[0]; + } + + private async requireParent( + manager: EntityManager, + kind: InventoryStructureKind, + parentId: string | null, + ): Promise { + const expectedType = PARENT_TYPE_BY_KIND[kind]; + if (!expectedType) { + if (parentId) throw new BadRequestException({ code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT', message: `${kind === 'EMPRESA' ? 'Empresa' : 'Departamento'} no admite padre` }); + return null; + } + if (!parentId) throw new BadRequestException({ code: 'INVENTORY_PARENT_REQUIRED', message: `Seleccioná el nivel superior para crear ${kind.toLowerCase()}` }); + + const rows = (await manager.query(` + SELECT asset.id,asset.code,asset.name,lower(type.code) AS "typeCode", + asset.inventory_family_id AS "inventoryFamilyId", + CASE WHEN lower(type.code)='area' THEN asset.id ELSE asset.operational_area_id END AS "operationalAreaId", + asset.operator_company_id AS "operatorCompanyId" + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE' + FOR KEY SHARE OF asset + `, [parentId])) as ParentRow[]; + const parent = rows[0]; + if (!parent || parent.typeCode !== expectedType) { + throw new BadRequestException({ code: 'INVENTORY_PARENT_INVALID', message: `El registro padre debe ser ${expectedType}` }); + } + return parent; + } + + private async requireAreaCompanyRelation(manager: EntityManager, areaId: string, companyId: string): Promise { + const rows = await manager.query(` + SELECT 1 + FROM area_company_relations relation + JOIN assets company ON company.id=relation.company_id + JOIN asset_types company_type ON company_type.id=company.asset_type_id + WHERE relation.area_id=$1::uuid + AND relation.company_id=$2::uuid + AND relation.relation_role='OPERATOR' + AND relation.valid_until IS NULL + AND company.information_status<>'INACTIVE' + AND company_type.operational_role='COMPANY' + AND company_type.is_active=true + LIMIT 1 + FOR KEY SHARE OF relation + `, [areaId, companyId]); + if (!rows[0]) { + throw new BadRequestException({ + code: 'INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA', + message: 'La Empresa seleccionada no está vinculada como explotadora del Área', + }); + } + } + + private async requireFamily( + manager: EntityManager, + kind: InventoryStructureKind, + familyId: string | null, + parent: ParentRow | null, + ): Promise { + if (kind !== 'INSTALACION' && kind !== 'SUBINSTALACION') { + if (familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_NOT_ALLOWED', message: 'Este nivel no usa Tipo de Instalación/Subinstalación' }); + return null; + } + if (!familyId) throw new BadRequestException({ code: 'INVENTORY_FAMILY_REQUIRED', message: `Seleccioná el tipo de ${kind === 'INSTALACION' ? 'Instalación' : 'Subinstalación'}` }); + + const rows = (await manager.query(` + SELECT id,code,name,level::text AS level + FROM inventory_families + WHERE id=$1::uuid AND is_active=true + LIMIT 1 + FOR KEY SHARE + `, [familyId])) as FamilyRow[]; + const family = rows[0]; + if (!family) throw new NotFoundException({ code: 'INVENTORY_FAMILY_NOT_FOUND', message: 'El tipo seleccionado no existe o está oculto' }); + const expected = kind === 'INSTALACION' ? 'INSTALLATION' : 'SUBINSTALLATION'; + if (family.level !== expected) throw new BadRequestException({ code: 'INVENTORY_FAMILY_LEVEL_INVALID', message: 'El tipo seleccionado no corresponde a este nivel' }); + + if (kind === 'SUBINSTALACION') { + if (!parent?.inventoryFamilyId) throw new BadRequestException({ code: 'INVENTORY_PARENT_FAMILY_REQUIRED', message: 'La Instalación debe tener un tipo configurado' }); + const compatible = await manager.query(` + SELECT 1 FROM inventory_family_parent_rules + WHERE child_family_id=$1::uuid AND parent_family_id=$2::uuid + LIMIT 1 + `, [family.id, parent.inventoryFamilyId]); + if (!compatible[0]) throw new BadRequestException({ code: 'INVENTORY_SUBINSTALLATION_FAMILY_PARENT_INVALID', message: 'Ese tipo de Subinstalación no está habilitado dentro de la Instalación seleccionada' }); + } + return family; + } + + private generatedCode(kind: InventoryStructureKind, name: string): string { + const prefix = kind === 'EMPRESA' ? 'EMP' + : kind === 'DEPARTAMENTO' ? 'DEP' + : kind === 'AREA' ? 'AREA' + : kind === 'YACIMIENTO' ? 'YAC' + : kind === 'INSTALACION' ? 'INST' + : 'SUB'; + const readable = name.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toUpperCase() + .replace(/[^A-Z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 48) || 'REGISTRO'; + return `${prefix}-${readable}-${randomUUID().slice(0, 8).toUpperCase()}`.slice(0, 120); + } + + private async loadView(manager: EntityManager, id: string) { + const rows = await manager.query(` + SELECT asset.id,asset.code,asset.name,asset.common_name AS "commonName",asset.description, + asset.information_status AS "informationStatus",asset.operational_status AS "operationalStatus", + JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type, + CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, + CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", + CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',COALESCE(profile.legal_name,company.name)) END AS "operatorCompany", + 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 "inventoryFamily", + asset.created_at AS "createdAt",asset.updated_at AS "updatedAt" + FROM assets asset + JOIN asset_types type ON type.id=asset.asset_type_id + LEFT JOIN assets parent ON parent.id=asset.parent_id + LEFT JOIN assets area ON area.id=asset.operational_area_id + LEFT JOIN assets company ON company.id=asset.operator_company_id + LEFT JOIN organization_profiles profile ON profile.asset_id=company.id + LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id + WHERE asset.id=$1::uuid + `, [id]); + return rows[0]; + } +} diff --git a/api-v3/src/database/migrations/1790110200000-f7-simple-inventory-context.ts b/api-v3/src/database/migrations/1790110200000-f7-simple-inventory-context.ts new file mode 100644 index 0000000..432e8bd --- /dev/null +++ b/api-v3/src/database/migrations/1790110200000-f7-simple-inventory-context.ts @@ -0,0 +1,179 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F7SimpleInventoryContext1790110200000 implements MigrationInterface { + name = 'F7SimpleInventoryContext1790110200000'; + + public async up(q: QueryRunner): Promise { + await q.query(` + UPDATE asset_types + SET description='Yacimiento. Pertenece a un Área, tiene una única Empresa operadora vigente y puede recibir Hallazgos.', + updated_at=CURRENT_TIMESTAMP + WHERE lower(code)='yacimiento' + `); + + await q.query(` + CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE + kind text; + asset_role asset_type_operational_role; + parent_kind text; + parent_area uuid; + parent_company uuid; + parent_family uuid; + company_role asset_type_operational_role; + family_level text; + BEGIN + SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id; + IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF; + + IF asset_role='COMPANY'::asset_type_operational_role THEN + IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF; + NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL; + NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + RETURN NEW; + ELSIF kind='departamento' THEN + IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF; + NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL; + NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + RETURN NEW; + END IF; + + IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF; + SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id + INTO parent_kind,parent_area,parent_company,parent_family + FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id + WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE'; + IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF; + + IF kind='area' THEN + IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF; + NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL; + NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + ELSIF kind='yacimiento' THEN + IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF; + IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere una Empresa operadora'; END IF; + SELECT type.operational_role INTO company_role + FROM assets company JOIN asset_types type ON type.id=company.asset_type_id + WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa operadora del Yacimiento no es válida'; + END IF; + IF NOT EXISTS( + SELECT 1 FROM area_company_relations relation + WHERE relation.area_id=NEW.parent_id + AND relation.company_id=NEW.operator_company_id + AND relation.relation_role='OPERATOR'::area_organization_role + AND relation.valid_until IS NULL + ) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa del Yacimiento debe estar vinculada como explotadora del Área'; + END IF; + IF NEW.concession_type_id IS NOT NULL + AND NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de concesión indicado no es válido'; + END IF; + IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; + END IF; + NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + ELSIF kind='instalacion' THEN + IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF; + SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true; + IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF; + IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Yacimiento debe tener una Empresa operadora'; END IF; + NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true; + ELSIF kind='subinstalacion' THEN + IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF; + SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true; + IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF; + IF NOT EXISTS( + SELECT 1 FROM inventory_family_parent_rules rule + WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family + ) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no está habilitado dentro del Tipo de instalación padre'; + END IF; + IF parent_company IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Instalación debe pertenecer a un Yacimiento con Empresa operadora'; END IF; + NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true; + END IF; + RETURN NEW; + END $$ + `); + } + + public async down(q: QueryRunner): Promise { + await q.query(` + UPDATE asset_types + SET description='Yacimiento. Pertenece a un Área y define directamente Tipo de concesión y Empresa relacionada.', + updated_at=CURRENT_TIMESTAMP + WHERE lower(code)='yacimiento' + `); + + await q.query(` + CREATE OR REPLACE FUNCTION enforce_authoritative_inventory_relationships() + RETURNS trigger LANGUAGE plpgsql AS $$ + DECLARE + kind text; + asset_role asset_type_operational_role; + parent_kind text; + parent_area uuid; + parent_company uuid; + parent_family uuid; + company_role asset_type_operational_role; + family_level text; + BEGIN + SELECT lower(code),operational_role INTO kind,asset_role FROM asset_types WHERE id=NEW.asset_type_id; + IF kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='asset type does not exist'; END IF; + + IF asset_role='COMPANY'::asset_type_operational_role THEN + IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa no admite padre'; END IF; + NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL; + NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + RETURN NEW; + ELSIF kind='departamento' THEN + IF NEW.parent_id IS NOT NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Departamento no admite padre'; END IF; + NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL; + NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + RETURN NEW; + END IF; + + IF NEW.parent_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El nivel requiere un padre estructural'; END IF; + SELECT lower(parent_type.code), parent.operational_area_id, parent.operator_company_id, parent.inventory_family_id + INTO parent_kind,parent_area,parent_company,parent_family + FROM assets parent JOIN asset_types parent_type ON parent_type.id=parent.asset_type_id + WHERE parent.id=NEW.parent_id AND parent.information_status<>'INACTIVE'; + IF parent_kind IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El padre estructural no existe o está inactivo'; END IF; + + IF kind='area' THEN + IF parent_kind<>'departamento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Área debe pertenecer a un Departamento'; END IF; + NEW.operational_area_id:=NULL; NEW.operator_company_id:=NULL; NEW.concession_type_id:=NULL; + NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + ELSIF kind='yacimiento' THEN + IF parent_kind<>'area' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento debe pertenecer a un Área'; END IF; + IF NEW.operator_company_id IS NULL THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere Empresa relacionada'; END IF; + SELECT type.operational_role INTO company_role FROM assets company JOIN asset_types type ON type.id=company.asset_type_id + WHERE company.id=NEW.operator_company_id AND company.information_status<>'INACTIVE' AND type.is_active=true; + IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='La Empresa relacionada del Yacimiento no es válida'; END IF; + IF NEW.concession_type_id IS NULL OR NOT EXISTS(SELECT 1 FROM concession_types c WHERE c.id=NEW.concession_type_id AND c.is_active=true) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento requiere un Tipo de concesión válido'; + END IF; + IF NEW.operational_area_id IS NOT NULL AND NEW.operational_area_id<>NEW.parent_id THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Área operativa del Yacimiento debe coincidir con su Área padre'; END IF; + NEW.operational_area_id:=NEW.parent_id; NEW.inventory_family_id:=NULL; NEW.is_inventory_instance:=false; + ELSIF kind='instalacion' THEN + IF parent_kind<>'yacimiento' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación debe pertenecer a un Yacimiento'; END IF; + SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true; + IF family_level IS DISTINCT FROM 'INSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Instalación requiere un Tipo de instalación válido'; END IF; + NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true; + ELSIF kind='subinstalacion' THEN + IF parent_kind<>'instalacion' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación debe pertenecer a una Instalación'; END IF; + SELECT level::text INTO family_level FROM inventory_families WHERE id=NEW.inventory_family_id AND is_active=true; + IF family_level IS DISTINCT FROM 'SUBINSTALLATION' THEN RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Subinstalación requiere un Tipo de subinstalación válido'; END IF; + IF NOT EXISTS(SELECT 1 FROM inventory_family_parent_rules rule WHERE rule.child_family_id=NEW.inventory_family_id AND rule.parent_family_id=parent_family) THEN + RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='El Tipo de subinstalación no es compatible con el Tipo de instalación padre'; + END IF; + NEW.operational_area_id:=parent_area; NEW.operator_company_id:=parent_company; NEW.concession_type_id:=NULL; NEW.is_inventory_instance:=true; + END IF; + RETURN NEW; + END $$ + `); + } +} diff --git a/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts b/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts index c7aeac7..43adf9b 100644 --- a/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts +++ b/api-v3/test/unit/f5-1-clean-manual-inventory-contract.test.ts @@ -32,7 +32,7 @@ test('F5.1 canonical hierarchy starts at Departamento and Area is no longer root assert.match(migration, /SET can_be_root=false/); }); -test('Authoritative manual creation keeps Area physical and stores Company plus concession on Yacimiento', () => { +test('Historical authoritative endpoint keeps the former Yacimiento Company plus concession contract for compatibility', () => { const structure = source('src/asset-master/inventory-structure.service.ts'); const dto = source('src/asset-master/dto/create-inventory-structure.dto.ts'); @@ -66,27 +66,60 @@ test('F5.1 Finding Catalog defaults to associated findings and exposes all items assert.match(panel, /Esta clasificación todavía no tiene Hallazgos asociados/); }); -test('Authoritative Configuration presents exact physical relationships and technical families', () => { +test('F7 Inventory Configuration exposes only simple types, containment and fields', () => { const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx'); - assert.match(configPage, /El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento/); - assert.match(configPage, /Tipo de concesión/); - assert.match(configPage, /Empresa relacionada/); - assert.match(configPage, /Yacimiento → Instalación/); - assert.match(configPage, /Instalación → Subinstalación/); - assert.match(configPage, /installationFamilies\.map/); - assert.match(configPage, /subinstallationFamilies\.map/); - assert.match(configPage, /Hallazgos asociados/); + assert.match(configPage, /Tipos y campos/); + assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/); + assert.match(configPage, /Un Área puede estar vinculada a varias Empresas/); + assert.match(configPage, /Cada Yacimiento elige una sola Empresa operadora/); + assert.match(configPage, /Tipos de Instalación/); + assert.match(configPage, /Tipos de Subinstalación/); + assert.match(configPage, /Puede estar dentro de/); + assert.match(configPage, /\{ value: 'TEXT', label: 'Texto' \}/); + assert.match(configPage, /\{ value: 'NUMBER', label: 'Número' \}/); + assert.match(configPage, /\{ value: 'DATE', label: 'Fecha' \}/); + assert.match(configPage, /\{ value: 'BOOLEAN', label: 'Sí \/ No' \}/); + assert.doesNotMatch(configPage, /Hallazgos asociados/); }); -test('Authoritative Web creation exposes Departamento as root and Yacimiento as operational owner', () => { +test('F7 Web creation keeps the fixed hierarchy and asks only the required simple context', () => { const createPage = source('../web-v2/src/pages/InventoryCreatePage.tsx'); const configPage = source('../web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx'); + assert.match(createPage, /kind: 'DEPARTAMENTO'/); - assert.match(createPage, /AREA:'Departamento'/); - assert.match(createPage, /Empresa relacionada/); - assert.match(createPage, /Tipo de concesión/); - assert.match(createPage, /Datos opcionales/); - assert.match(configPage, /Departamento<\/strong>/); - assert.match(configPage, /Yacimiento<\/strong>/); + assert.match(createPage, /AREA: 'Departamento'/); + assert.match(createPage, /YACIMIENTO: 'Área'/); + assert.match(createPage, /INSTALACION: 'Yacimiento'/); + assert.match(createPage, /SUBINSTALACION: 'Instalación'/); + assert.match(createPage, /listCompaniesForInventoryArea/); + assert.match(createPage, /Empresa operadora/); + assert.match(createPage, /ALTA RÁPIDA/); + assert.doesNotMatch(createPage, /Tipo de concesión/); + assert.match(configPage, /Departamento → Área → Yacimiento → Instalación → Subinstalación/); +}); + +test('F7 canonical detail uses one short everyday profile and keeps advanced administration separate', () => { + const detailRoute = source('../web-v2/src/pages/InventoryDetailPage.tsx'); + const detail = source('../web-v2/src/pages/SimpleInventoryDetailPage.tsx'); + + assert.match(detailRoute, /isSimpleInventoryAsset\(asset\) && !advanced/); + assert.match(detailRoute, /ResumenActividadUbicaciónFotosCambios { +test('F7 WEB creation selects one Area-linked Company for Yacimiento and retires concession from the simple flow', () => { const page = source('../web-v2/src/pages/InventoryCreatePage.tsx'); - const structure = source('src/asset-master/inventory-structure.service.ts'); + const api = source('../web-v2/src/lib/inventoryStructureApi.ts'); + const simpleStructure = source('src/asset-master/simple-inventory-structure.service.ts'); + const migration = source('src/database/migrations/1790110200000-f7-simple-inventory-context.ts'); - assert.match(page, /requiresYacimientoContext = kind === 'YACIMIENTO'/); - assert.match(page, /Empresa relacionada/); - assert.match(page, /Tipo de concesión/); - assert.match(page, /operatorCompanyId:operatorCompanyId \|\| null/); - assert.match(page, /concessionTypeId:concessionTypeId \|\| null/); - assert.match(structure, /Empresa, Departamento, Área y Yacimiento no llevan clasificación técnica/); + assert.match(page, /const requiresCompany = kind === 'YACIMIENTO'/); + assert.match(page, /listCompaniesForInventoryArea\(parentId\)/); + assert.match(page, /Empresa operadora/); + assert.match(page, /operatorCompanyId: operatorCompanyId \|\| null/); + assert.doesNotMatch(page, /Tipo de concesión/); + assert.doesNotMatch(page, /concessionTypeId/); + assert.match(api, /inventory-structure\/simple/); + assert.match(simpleStructure, /requireAreaCompanyRelation/); + assert.match(simpleStructure, /INVENTORY_YACIMIENTO_COMPANY_NOT_IN_AREA/); + assert.match(migration, /La Empresa del Yacimiento debe estar vinculada como explotadora del Área/); + assert.match(migration, /NEW\.operational_area_id:=NEW\.parent_id/); }); diff --git a/docs/F7_SIMPLE_INVENTORY_MODEL.md b/docs/F7_SIMPLE_INVENTORY_MODEL.md new file mode 100644 index 0000000..2bb0cb3 --- /dev/null +++ b/docs/F7_SIMPLE_INVENTORY_MODEL.md @@ -0,0 +1,136 @@ +# F7 · Modelo simple de Inventarios + +Este documento fija el nuevo eje funcional de Inventarios para DH Inspección V2. Su objetivo es reducir la complejidad visible y orientar toda la experiencia al trabajo real de inspección. + +## 1. Jerarquía + +La estructura física es única: + +**Departamento → Área → Yacimiento → Instalación → Subinstalación** + +- Departamento y Área organizan el territorio. +- Yacimiento, Instalación y Subinstalación son niveles válidos para registrar Hallazgos. +- Una Instalación pertenece a un Yacimiento. +- Una Subinstalación pertenece a una Instalación. + +## 2. Empresas + +- Un Área puede estar explotada por varias Empresas al mismo tiempo. +- Cada Yacimiento tiene una sola Empresa operadora vigente. +- La Empresa del Yacimiento se elige entre las Empresas vinculadas al Área. +- Instalaciones y Subinstalaciones heredan el contexto de Empresa desde su Yacimiento. +- Cambiar la Empresa de un Yacimiento no mueve ni recrea su Inventario físico. + +## 3. Datos simples + +Todos los registros usan una base común reducida: + +- Nombre. +- Código, opcional y autogenerable. +- Ubicación jerárquica. +- Estado. +- GPS cuando corresponda. +- Observaciones sólo cuando aporten valor. + +Departamento, Área y Yacimiento no necesitan un constructor técnico complejo. + +Instalaciones y Subinstalaciones pueden tener campos específicos configurados por el Superadmin técnico. + +Los tipos de campo visibles para la puesta a punto deben mantenerse simples: + +- Texto. +- Número. +- Fecha. +- Sí / No. + +Teléfono, email o identificadores simples pueden resolverse como Texto. GPS es un dato básico del registro, no un atributo técnico configurable. + +## 4. Administración de tipos + +Sólo el Superadmin técnico modifica la configuración. + +La pantalla administrativa debe permitir únicamente: + +1. Crear/ocultar Tipos de Instalación. +2. Crear/ocultar Tipos de Subinstalación. +3. Definir qué Tipos de Subinstalación puede contener cada Tipo de Instalación. +4. Agregar, mostrar/ocultar y marcar como obligatorios los campos simples de cada tipo. + +Los términos técnicos internos como `inventory_families`, reglas de compatibilidad o schemas no deben exponerse al usuario. + +## 5. Cambio de tipo / función + +La identidad física del elemento no cambia cuando cambia su función. + +Ejemplo: `TK-57` sigue siendo `TK-57`, aunque hoy funcione como tanque de petróleo y mañana como tanque de agua. + +El cambio debe registrarse cronológicamente con: + +- tipo / función anterior; +- tipo / función nueva; +- fecha efectiva; +- observación opcional. + +No se crea un Inventario nuevo para representar un cambio de función. + +## 6. Cronología útil + +Para Yacimiento, Instalación y Subinstalación la cronología debe concentrar sólo hechos operativos relevantes: + +- Hallazgos. +- Acta asociada. +- Empresa operadora correspondiente a la fecha. +- Cambio de tipo / función. +- Cambio de ubicación cuando corresponda. +- Alta e inactivación. + +La experiencia normal no debe obligar a navegar por procedencia, snapshots, registros técnicos o paneles administrativos separados. + +## 7. Filosofía de la APK + +La APK existe para **inspeccionar, generar Acta y generar Informe**. + +El Inspector no administra Inventarios como tarea separada. + +La mayoría de las altas reales ocurren en campo mientras se registra un Hallazgo. + +Flujo: + +1. La Inspección se planifica desde WEB y ya conoce el Yacimiento. +2. El Inspector abre la Inspección al llegar. +3. Pulsa `+ Crear hallazgo`. +4. Decide si el Hallazgo corresponde a Yacimiento, Instalación o Subinstalación. +5. Si la Instalación no existe, puede crearla rápidamente sin abandonar el flujo. +6. Si necesita una Subinstalación que no existe, puede crearla rápidamente dentro de la Instalación seleccionada. +7. El catálogo ayuda, pero `OTRO` siempre está disponible. +8. Con el tiempo, el Inventario se completa naturalmente y disminuye la necesidad de altas nuevas. + +## 8. Planificación y recorrido + +La planificación WEB define: + +- Área. +- Empresa. +- Yacimiento. +- Inspector. +- Fecha/hora. +- Instalaciones/Subinstalaciones elegidas preventivamente. +- pendientes por vencer, mostrados por elemento a controlar. +- pendientes vencidos, mostrados por elemento a controlar. + +La APK presenta ese conjunto como recorrido sugerido. No es una ruta rígida: el Inspector puede registrar Hallazgos nuevos en cualquier momento. + +## 9. Actas e Informes + +- Abrir una Inspección no crea automáticamente un Acta. +- El Inspector pulsa `+ Nueva Acta` cuando empieza a documentar. +- El número definitivo del Acta se genera al cerrarla. +- Una Inspección puede tener varias Actas. +- Al cerrar un Acta se pueden agregar uno o más acompañantes con Nombre + Email. +- El Acta se envía a Empresa, Inspector y acompañantes. +- El Informe se genera automáticamente en Word y se envía únicamente al Inspector que realizó el Acta. +- La firma o validación legal definitiva queda para una etapa posterior; la prioridad actual es la usabilidad. + +## 10. Regla de diseño + +Si una pantalla, campo o estado necesita explicación técnica para ser usado correctamente, debe simplificarse antes de incorporarse a la experiencia cotidiana. diff --git a/scripts/check-f3-1-web-contract.sh b/scripts/check-f3-1-web-contract.sh index f9567f8..5f1beda 100755 --- a/scripts/check-f3-1-web-contract.sh +++ b/scripts/check-f3-1-web-contract.sh @@ -4,37 +4,59 @@ set -Eeuo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" LAYOUT="$ROOT/web-v2/src/layout/AppLayout.tsx" APP="$ROOT/web-v2/src/app/App.tsx" -PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx" +CREATE_PAGE="$ROOT/web-v2/src/pages/InventoryCreatePage.tsx" +ADMIN_PAGE="$ROOT/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx" +API="$ROOT/web-v2/src/lib/inventoryStructureApi.ts" -for file in "$LAYOUT" "$APP" "$PAGE"; do +for file in "$LAYOUT" "$APP" "$CREATE_PAGE" "$ADMIN_PAGE" "$API"; do test -f "$file" || { - echo "F4 WEB contract: falta $file" + echo "F7 WEB contract: falta $file" exit 1 } done if grep -Fq "label: 'Relevamientos'" "$LAYOUT"; then - echo "F4 WEB contract: Relevamientos no debe volver al menú principal" + echo "F7 WEB contract: Relevamientos no debe volver al menú principal" exit 1 fi if grep -Fq 'path="/relevamiento/*"' "$APP"; then - echo "F4 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas" + echo "F7 WEB contract: Survey/Relevamiento no debe volver a exponer rutas activas" exit 1 fi grep -Fq 'path="/inventarios/nuevo"' "$APP" grep -Fq 'InventoryCreatePage' "$APP" -for kind in AREA YACIMIENTO INSTALACION SUBINSTALACION; do - grep -Fq "kind: '$kind'" "$PAGE" +for kind in DEPARTAMENTO AREA YACIMIENTO INSTALACION SUBINSTALACION; do + grep -Fq "kind: '$kind'" "$CREATE_PAGE" done -if grep -Fq "kind: 'EQUIPO'" "$PAGE"; then - echo "F4 WEB contract: el alta guiada no debe restaurar EQUIPO como quinto nivel estructural" +if grep -Fq "kind: 'EQUIPO'" "$CREATE_PAGE"; then + echo "F7 WEB contract: EQUIPO no debe restaurarse como nivel estructural" exit 1 fi -grep -Fq 'getInventoryFamilyFindings' "$PAGE" +# F7: Yacimiento elige una Empresa ya vinculada al Área; concesión deja de ser una decisión del alta cotidiana. +grep -Fq 'listCompaniesForInventoryArea' "$CREATE_PAGE" +if grep -Fq 'concessionTypeId' "$CREATE_PAGE"; then + echo "F7 WEB contract: el alta simple no debe exponer Tipo de concesión" + exit 1 +fi -echo 'F4 WEB contract: OK' +# El alta normal usa el endpoint simple; el endpoint histórico queda sólo por compatibilidad transitoria. +grep -Fq "'/inventory-structure/simple'" "$API" + +# La configuración técnica visible se reduce a tipos, contención y campos sencillos. +grep -Fq 'Tipos y campos' "$ADMIN_PAGE" +grep -Fq 'Tipos de Instalación' "$ADMIN_PAGE" +grep -Fq 'Tipos de Subinstalación' "$ADMIN_PAGE" +grep -Fq '+ Agregar campo' "$ADMIN_PAGE" + +# El catálogo de Hallazgos no debe mezclarse con el formulario de alta de Inventario. +if grep -Fq 'getInventoryFamilyFindings' "$CREATE_PAGE"; then + echo "F7 WEB contract: el alta simple no debe cargar catálogo de Hallazgos" + exit 1 +fi + +echo 'F7 WEB contract: OK' diff --git a/web-v2/src/lib/inventoryStructureApi.ts b/web-v2/src/lib/inventoryStructureApi.ts index 3ff41ac..13bf683 100644 --- a/web-v2/src/lib/inventoryStructureApi.ts +++ b/web-v2/src/lib/inventoryStructureApi.ts @@ -22,6 +22,7 @@ export interface InventoryTechnicalValues { } export interface InventoryStructureLevel { kind:InventoryStructureKind; label:string; type:{id:string;code:string;name:string}; parentKind:InventoryStructureKind|null; requiresFamily:boolean; } export interface InventoryStructureOption { id:string; code:string; name:string; } +export interface InventoryAreaCompanyOption extends InventoryStructureOption { commonName?:string|null; typeName?:string; } export interface InventoryStructureOptions { independentMasters:InventoryStructureLevel[]; levels:InventoryStructureLevel[]; @@ -45,7 +46,7 @@ export interface CreatedInventoryStructure { type:{id:string;code:string;name:string}; parent:null|{id:string;code:string;name:string}; operationalArea?:null|{id:string;code:string;name:string}; operatorCompany?:null|{id:string;code:string;name:string}; concessionType?:null|{id:string;code:string;name:string}; - inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels:string[]}; + inventoryFamily:null|{id:string;code:string;name:string;level:string;informationLabels?:string[]}; } export function getInventoryStructureOptions(){return apiRequest('/inventory-structure');} @@ -53,6 +54,9 @@ export async function listInventoryStructureParents(kind:InventoryStructureKind, const query=new URLSearchParams(); if(search.trim()) query.set('search',search.trim()); const suffix=query.size?`?${query}`:''; return (await apiRequest<{data:InventoryStructureParent[]}>(`/inventory-structure/parents/${kind}${suffix}`)).data; } +export async function listCompaniesForInventoryArea(areaId:string){ + return (await apiRequest<{data:InventoryAreaCompanyOption[]}>(`/asset-operational-relations/areas/${areaId}/companies`)).data; +} export function getInventoryFamilyFindings(familyId:string){return apiRequest(`/inventory-families/${familyId}/findings`);} export function getInventoryFamilyAttributes(familyId:string){return apiRequest(`/inventory-families/${familyId}/attributes`);} export function getInventoryTechnicalValues(assetId:string){return apiRequest(`/assets/${assetId}/technical-values`);} @@ -63,4 +67,4 @@ export function updateInventoryFamily(familyId:string,input:{name?:string;parent export function createInventoryFamilyAttribute(familyId:string,input:{code:string;name:string;dataType:InventoryFamilyAttributeDataType;isRequired?:boolean;unit?:string|null;options?:string[];sortOrder?:number}){return apiRequest(`/inventory-families/${familyId}/attributes`,{method:'POST',body:JSON.stringify(input)});} export function updateInventoryFamilyAttribute(familyId:string,attributeId:string,input:{name?:string;dataType?:InventoryFamilyAttributeDataType;isRequired?:boolean;isActive?:boolean;unit?:string|null;options?:string[]|null;sortOrder?:number}){return apiRequest(`/inventory-families/${familyId}/attributes/${attributeId}`,{method:'PATCH',body:JSON.stringify(input)});} export function replaceInventoryFamilyFindings(familyId:string,input:{itemIds:string[];reason:string}){return apiRequest(`/inventory-families/${familyId}/findings`,{method:'PUT',body:JSON.stringify(input)});} -export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest('/inventory-structure',{method:'POST',body:JSON.stringify(input)});} +export function createInventoryStructure(input:{kind:InventoryStructureKind;code?:string|null;name:string;commonName?:string|null;parentId?:string|null;familyId?:string|null;operatorCompanyId?:string|null;concessionTypeId?:string|null;description?:string|null}){return apiRequest('/inventory-structure/simple',{method:'POST',body:JSON.stringify(input)});} diff --git a/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx b/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx index 5b2479d..a52f071 100644 --- a/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx +++ b/web-v2/src/pages/AuthoritativeInventoryConfigPage.tsx @@ -1,203 +1,305 @@ import { useEffect, useMemo, useState } from 'react'; -import { Link } from 'react-router'; +import { useAuth } from '../auth/AuthContext'; import { Alert, LoadingBlock, errorMessage } from '../components/Feedback'; import { Icon } from '../components/Icon'; -import { getFindingCatalogAdmin, listAssetTypes } from '../lib/api'; -import type { AssetType, FindingAdminCatalog } from '../lib/api'; -import { listInventoryFamiliesAdmin } from '../lib/inventoryStructureApi'; -import type { InventoryFamily } from '../lib/inventoryStructureApi'; +import { + createInventoryFamily, + createInventoryFamilyAttribute, + getInventoryFamilyAttributes, + listInventoryFamiliesAdmin, + updateInventoryFamily, + updateInventoryFamilyAttribute, +} from '../lib/inventoryStructureApi'; +import type { + InventoryFamily, + InventoryFamilyAttribute, + InventoryFamilyAttributeDataType, +} from '../lib/inventoryStructureApi'; -type CanonicalKind = 'EMPRESA' | 'DEPARTAMENTO' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION'; +type FamilyLevel = 'INSTALLATION' | 'SUBINSTALLATION'; -type StructuralField = { - label: string; - detail: string; - required?: boolean; - relation?: boolean; -}; - -const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string; fields: StructuralField[] }> = [ - { - kind: 'EMPRESA', - label: 'Empresa', - description: 'Maestro independiente. La Empresa se relaciona directamente con uno o más Yacimientos.', - fields: [{ label: 'Nombre', detail: 'Nombre de la Empresa / Operadora.', required: true }], - }, - { - kind: 'DEPARTAMENTO', - label: 'Departamento', - description: 'Raíz territorial. No depende de ningún otro nivel.', - fields: [{ label: 'Nombre', detail: 'Nombre del Departamento.', required: true }], - }, - { - kind: 'AREA', - label: 'Área', - description: 'El Área sólo tiene Nombre y pertenece obligatoriamente a un Departamento.', - fields: [ - { label: 'Departamento', detail: 'Relación obligatoria Departamento → Área.', required: true, relation: true }, - { label: 'Nombre', detail: 'Nombre del Área.', required: true }, - ], - }, - { - kind: 'YACIMIENTO', - label: 'Yacimiento', - description: 'El Yacimiento concentra el contexto operativo: Área, Tipo de concesión y Empresa relacionada.', - fields: [ - { label: 'Área', detail: 'Relación obligatoria Área → Yacimiento.', required: true, relation: true }, - { label: 'Tipo de concesión', detail: 'Explotación o Exploración, según la fuente.', required: true, relation: true }, - { label: 'Empresa relacionada', detail: 'Empresa / Operadora asociada directamente al Yacimiento.', required: true, relation: true }, - { label: 'Nombre', detail: 'Nombre del Yacimiento. Puede repetirse en otra Área.', required: true }, - ], - }, - { - kind: 'INSTALACION', - label: 'Instalación', - description: 'Cada Instalación pertenece a un Yacimiento y posee una clasificación técnica.', - fields: [ - { label: 'Yacimiento', detail: 'Relación obligatoria Yacimiento → Instalación.', required: true, relation: true }, - { label: 'Tipo de instalación', detail: 'Clasificación técnica tomada del modelo de Instalaciones.', required: true, relation: true }, - ], - }, - { - kind: 'SUBINSTALACION', - label: 'Subinstalación', - description: 'Cada Subinstalación pertenece a una Instalación y usa una clasificación compatible con ella.', - fields: [ - { label: 'Instalación', detail: 'Relación obligatoria Instalación → Subinstalación.', required: true, relation: true }, - { label: 'Tipo de subinstalación', detail: 'Clasificación técnica compatible con el Tipo de instalación padre.', required: true, relation: true }, - ], - }, +const FIELD_TYPES: Array<{ value: InventoryFamilyAttributeDataType; label: string }> = [ + { value: 'TEXT', label: 'Texto' }, + { value: 'NUMBER', label: 'Número' }, + { value: 'DATE', label: 'Fecha' }, + { value: 'BOOLEAN', label: 'Sí / No' }, ]; -const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] }; +function fieldTypeLabel(value: InventoryFamilyAttributeDataType) { + return FIELD_TYPES.find((item) => item.value === value)?.label + ?? (value === 'DATETIME' ? 'Fecha y hora' : value === 'SELECT' ? 'Lista' : value); +} -function canonicalType(types: AssetType[], kind: CanonicalKind): AssetType | null { - if (kind === 'EMPRESA') return types.find((type) => type.operationalRole === 'COMPANY' && type.isActive) ?? null; - if (kind === 'AREA') return types.find((type) => type.operationalRole === 'AREA' && type.isActive) ?? null; - return types.find((type) => type.code.toLowerCase() === kind.toLowerCase() && type.isActive) ?? null; +function fieldCode(name: string) { + return name + .normalize('NFD') + .replace(/[\u0300-\u036f]/g, '') + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, '_') + .replace(/^_+|_+$/g, '') + .slice(0, 100) || `campo_${Date.now()}`; } export function AuthoritativeInventoryConfigPage() { - const [types, setTypes] = useState([]); + const { hasPermission } = useAuth(); + const canManage = hasPermission('asset_types.manage'); const [families, setFamilies] = useState([]); - const [catalog, setCatalog] = useState(EMPTY_CATALOG); - const [selectedKind, setSelectedKind] = useState('AREA'); + const [level, setLevel] = useState('INSTALLATION'); + const [selectedFamilyId, setSelectedFamilyId] = useState(''); + const [attributes, setAttributes] = useState([]); + const [newTypeName, setNewTypeName] = useState(''); + const [newTypeParents, setNewTypeParents] = useState([]); + const [newFieldName, setNewFieldName] = useState(''); + const [newFieldType, setNewFieldType] = useState('TEXT'); + const [newFieldRequired, setNewFieldRequired] = useState(false); const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); const [error, setError] = useState(''); + const [success, setSuccess] = useState(''); + + const refreshFamilies = async () => { + const loaded = await listInventoryFamiliesAdmin(); + setFamilies(loaded); + return loaded; + }; useEffect(() => { - Promise.all([listAssetTypes(), listInventoryFamiliesAdmin(), getFindingCatalogAdmin()]) - .then(([loadedTypes, loadedFamilies, loadedCatalog]) => { - setTypes(loadedTypes); - setFamilies(loadedFamilies); - setCatalog(loadedCatalog); - }) + refreshFamilies() .catch((requestError) => setError(errorMessage(requestError))) .finally(() => setLoading(false)); }, []); - const level = LEVELS.find((item) => item.kind === selectedKind) ?? LEVELS[0]!; - const selectedType = canonicalType(types, selectedKind); - const commonAttributes = useMemo(() => { - const attributes = selectedType?.attributes.filter((attribute) => attribute.isActive) ?? []; - return selectedKind === 'INSTALACION' - ? attributes.filter((attribute) => attribute.code !== 'tipo_instalacion') - : attributes; - }, [selectedKind, selectedType]); - const installationFamilies = families.filter((family) => family.level === 'INSTALLATION' && family.isActive !== false); - const subinstallationFamilies = families.filter((family) => family.level === 'SUBINSTALLATION' && family.isActive !== false); - const activeFindings = catalog.items.filter((item) => item.isActive).length; + const installationFamilies = useMemo( + () => families.filter((family) => family.level === 'INSTALLATION'), + [families], + ); + const visibleFamilies = useMemo( + () => families.filter((family) => family.level === level), + [families, level], + ); + const selectedFamily = families.find((family) => family.id === selectedFamilyId) ?? null; + + useEffect(() => { + const first = visibleFamilies.find((family) => family.isActive !== false) ?? visibleFamilies[0] ?? null; + setSelectedFamilyId((current) => visibleFamilies.some((family) => family.id === current) ? current : first?.id ?? ''); + }, [level, visibleFamilies]); + + useEffect(() => { + if (!selectedFamilyId) { + setAttributes([]); + return; + } + getInventoryFamilyAttributes(selectedFamilyId) + .then((result) => setAttributes(result.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)))) + .catch((requestError) => setError(errorMessage(requestError))); + }, [selectedFamilyId]); + + const run = async (action: () => Promise, message: string) => { + setSaving(true); + setError(''); + setSuccess(''); + try { + await action(); + setSuccess(message); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(false); + } + }; + + const createType = async () => { + if (!newTypeName.trim()) return; + if (level === 'SUBINSTALLATION' && newTypeParents.length === 0) { + setError('Elegí al menos un Tipo de Instalación que pueda contener esta Subinstalación.'); + return; + } + await run(async () => { + const created = await createInventoryFamily({ + level, + name: newTypeName.trim(), + parentFamilyIds: level === 'SUBINSTALLATION' ? newTypeParents : undefined, + }); + await refreshFamilies(); + setSelectedFamilyId(created.id); + setNewTypeName(''); + setNewTypeParents([]); + }, `${level === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`); + }; + + const toggleParent = (parentId: string) => { + setNewTypeParents((current) => current.includes(parentId) + ? current.filter((id) => id !== parentId) + : [...current, parentId]); + }; + + const toggleSelectedParent = async (parentId: string) => { + if (!selectedFamily || selectedFamily.level !== 'SUBINSTALLATION') return; + const next = selectedFamily.parentFamilyIds.includes(parentId) + ? selectedFamily.parentFamilyIds.filter((id) => id !== parentId) + : [...selectedFamily.parentFamilyIds, parentId]; + if (next.length === 0) { + setError('Una Subinstalación debe quedar habilitada dentro de al menos un Tipo de Instalación.'); + return; + } + await run(async () => { + await updateInventoryFamily(selectedFamily.id, { parentFamilyIds: next }); + await refreshFamilies(); + }, 'Tipos permitidos actualizados.'); + }; + + const createField = async () => { + if (!selectedFamily || !newFieldName.trim()) return; + await run(async () => { + await createInventoryFamilyAttribute(selectedFamily.id, { + code: fieldCode(newFieldName), + name: newFieldName.trim(), + dataType: newFieldType, + isRequired: newFieldRequired, + sortOrder: attributes.length, + }); + const loaded = await getInventoryFamilyAttributes(selectedFamily.id); + setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))); + setNewFieldName(''); + setNewFieldType('TEXT'); + setNewFieldRequired(false); + }, 'Campo agregado.'); + }; + + const toggleField = async (attribute: InventoryFamilyAttribute, key: 'isRequired' | 'isActive') => { + if (!selectedFamily) return; + await run(async () => { + await updateInventoryFamilyAttribute(selectedFamily.id, attribute.id, { [key]: !attribute[key] }); + const loaded = await getInventoryFamilyAttributes(selectedFamily.id); + setAttributes(loaded.items.sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name))); + }, key === 'isActive' ? 'Visibilidad del campo actualizada.' : 'Obligatoriedad actualizada.'); + }; + + const toggleFamilyActive = async () => { + if (!selectedFamily) return; + await run(async () => { + await updateInventoryFamily(selectedFamily.id, { isActive: selectedFamily.isActive === false }); + await refreshFamilies(); + }, selectedFamily.isActive === false ? 'Tipo activado.' : 'Tipo desactivado.'); + }; if (loading) return ; return
- ADMINISTRACIÓN · MODELO AUTORITATIVO -

Configuración de Inventarios

-

Relaciones fijas cargadas desde los SQL definitivos. Los vínculos estructurales no son campos de texto editables.

+ ADMINISTRACIÓN · INVENTARIOS +

Tipos y campos

+

Configuración simple para la puesta a punto. Los usuarios de campo no ven esta pantalla.

- Agregar registro
+ {error && {error}} + {success && {success}}
- ESTRUCTURA FÍSICA -

Jerarquía obligatoria

-

Empresa es un maestro independiente y se asocia al Yacimiento. El árbol físico queda separado y sin ambigüedades.

+ ESTRUCTURA FIJA +

Departamento → Área → Yacimiento → Instalación → Subinstalación

+

Un Área puede estar vinculada a varias Empresas. Cada Yacimiento elige una sola Empresa operadora de las vinculadas a su Área.

-
-
1Departamentoraíz
-
2ÁreaDepartamento
-
3YacimientoÁrea + Empresa + concesión
-
4InstalaciónYacimiento
-
5SubinstalaciónInstalación
-
-
- -

Empresa: ya no pertenece al Área. La relación canónica es Yacimiento → Empresa relacionada.

+
+ +

Campos básicos del sistema: nombre, código, ubicación jerárquica, estado y GPS. No hace falta configurarlos acá.

-
-
-
- INFORMACIÓN ESTRUCTURAL -

{level.label}

-

{level.description}

-
- Modelo SQL -
-
- {LEVELS.map((item) => )} -
- -
- {level.fields.map((field, index) =>
- {index + 1} - - {field.label} - {field.detail} - - - {field.relation && Relación} - {field.required && Obligatorio} - -
)} - {commonAttributes.map((attribute, index) =>
- {level.fields.length + index + 1} - - {attribute.name} - {attribute.code} · Campo común del nivel - - {attribute.isRequired && Obligatorio} -
)} -
-
+
+ + +
-
MODELO TÉCNICO

Tipos de Instalación

{installationFamilies.length} clasificaciones exactas del SQL.

-
- {installationFamilies.map((family) =>
{family.name}{family.findingCount ?? 0} Hallazgos asociados{family.code}
)} +
+
+ TIPOS +

{level === 'INSTALLATION' ? 'Instalaciones' : 'Subinstalaciones'}

+

Seleccioná un tipo para administrar sus campos.

+
+ +
+ {visibleFamilies.length === 0 &&

Todavía no hay tipos configurados.

} + {visibleFamilies.map((family) => )} +
+ + {canManage &&
+

+ Nuevo tipo

+ + {level === 'SUBINSTALLATION' &&
Puede estar dentro de
+ {installationFamilies.filter((family) => family.isActive !== false).map((family) => )} +
} + +
}
+
-
MODELO TÉCNICO

Tipos de Subinstalación

{subinstallationFamilies.length} clasificaciones, cada una vinculada a su Tipo de instalación.

-
- {subinstallationFamilies.map((family) =>
{family.name}{family.parentFamilies.map((parent) => parent.name).join(' · ') || 'Sin padre'} · {family.findingCount ?? 0} Hallazgos
)} -
+ {!selectedFamily ?
Seleccioná un tipo para ver sus campos.
: <> +
+
+ {selectedFamily.level === 'INSTALLATION' ? 'INSTALACIÓN' : 'SUBINSTALACIÓN'} +

{selectedFamily.name}

+

Campos sencillos que se muestran al cargar este tipo.

+
+ {canManage && } +
+ + {selectedFamily.level === 'SUBINSTALLATION' &&
+ Puede estar dentro de: +
+ {installationFamilies.filter((family) => family.isActive !== false).map((family) => )} +
+
} + +
+ {attributes.length === 0 &&

Este tipo no tiene campos específicos. Puede usarse sólo con los datos básicos.

} + {attributes.map((attribute) =>
+ {attribute.sortOrder + 1} + + {attribute.name} + {fieldTypeLabel(attribute.dataType)}{attribute.isRequired ? ' · Obligatorio' : ' · Opcional'}{attribute.isActive ? '' : ' · Oculto'} + + {canManage && + + + } +
)} +
+ + {canManage && selectedFamily.isActive !== false &&
+

+ Agregar campo

+
+ + +
+ + +

Teléfono, email o identificadores simples se cargan como Texto. GPS es un dato básico del registro y no se configura como campo.

+
} + }
- -
-
-
HALLAZGOS

Catálogo contextual

{activeFindings} Hallazgos cargados con sus relaciones exactas a Instalaciones y Subinstalaciones.

- Abrir catálogo -
-
; } diff --git a/web-v2/src/pages/InventoryCreatePage.tsx b/web-v2/src/pages/InventoryCreatePage.tsx index ec822ee..32fcd91 100644 --- a/web-v2/src/pages/InventoryCreatePage.tsx +++ b/web-v2/src/pages/InventoryCreatePage.tsx @@ -6,13 +6,13 @@ import { Icon } from '../components/Icon'; import { getAsset } from '../lib/api'; import { createInventoryStructure, - getInventoryFamilyFindings, getInventoryStructureOptions, + listCompaniesForInventoryArea, listInventoryStructureParents, } from '../lib/inventoryStructureApi'; import type { + InventoryAreaCompanyOption, InventoryFamily, - InventoryFamilyFindings, InventoryStructureKind, InventoryStructureOptions, InventoryStructureParent, @@ -26,13 +26,24 @@ const KINDS: Array<{ kind: InventoryStructureKind; label: string }> = [ { kind: 'SUBINSTALACION', label: 'Subinstalación' }, { kind: 'EMPRESA', label: 'Empresa' }, ]; + const childKindByParentType: Record = { - departamento: 'AREA', area: 'YACIMIENTO', yacimiento: 'INSTALACION', instalacion: 'SUBINSTALACION', + departamento: 'AREA', + area: 'YACIMIENTO', + yacimiento: 'INSTALACION', + instalacion: 'SUBINSTALACION', }; -const parentLabelByKind: Partial> = { - AREA:'Departamento', YACIMIENTO:'Área', INSTALACION:'Yacimiento', SUBINSTALACION:'Instalación', + +const parentLabelByKind: Partial> = { + AREA: 'Departamento', + YACIMIENTO: 'Área', + INSTALACION: 'Yacimiento', + SUBINSTALACION: 'Instalación', }; -function kindLabel(kind: InventoryStructureKind) { return KINDS.find((item) => item.kind===kind)?.label ?? kind; } + +function kindLabel(kind: InventoryStructureKind) { + return KINDS.find((item) => item.kind === kind)?.label ?? kind; +} export function InventoryCreatePage() { const navigate = useNavigate(); @@ -44,102 +55,232 @@ export function InventoryCreatePage() { const [parentSearch, setParentSearch] = useState(''); const [parentId, setParentId] = useState(''); const [familyId, setFamilyId] = useState(''); + const [areaCompanies, setAreaCompanies] = useState([]); const [operatorCompanyId, setOperatorCompanyId] = useState(''); - const [concessionTypeId, setConcessionTypeId] = useState(''); - const [familyFindings, setFamilyFindings] = useState(null); const [name, setName] = useState(''); const [code, setCode] = useState(''); - const [commonName, setCommonName] = useState(''); - const [description, setDescription] = useState(''); const [loading, setLoading] = useState(true); const [saving, setSaving] = useState(false); const [error, setError] = useState(''); useEffect(() => { - getInventoryStructureOptions().then(async (loaded) => { - setOptions(loaded); - if (contextParentId) { - const parent = await getAsset(contextParentId); - const inferred = childKindByParentType[parent.type.code.toLowerCase()]; - if (inferred) { setKind(inferred); setParentId(parent.id); } - } - }).catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false)); + getInventoryStructureOptions() + .then(async (loaded) => { + setOptions(loaded); + if (contextParentId) { + const parent = await getAsset(contextParentId); + const inferred = childKindByParentType[parent.type.code.toLowerCase()]; + if (inferred) { + setKind(inferred); + setParentId(parent.id); + } + } + }) + .catch((requestError) => setError(errorMessage(requestError))) + .finally(() => setLoading(false)); }, [contextParentId]); const requiresParent = Boolean(parentLabelByKind[kind]); const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION'; - const requiresYacimientoContext = kind === 'YACIMIENTO'; + const requiresCompany = kind === 'YACIMIENTO'; const parentLabel = parentLabelByKind[kind] ?? ''; useEffect(() => { - if (!requiresParent) { setParents([]); setParentId(''); return; } + if (!requiresParent) { + setParents([]); + setParentId(''); + return; + } const timer = window.setTimeout(() => { - listInventoryStructureParents(kind,parentSearch).then((loaded) => { - setParents(loaded); - if (contextParentId && loaded.some((item) => item.id===contextParentId)) setParentId(contextParentId); - }).catch((requestError) => setError(errorMessage(requestError))); - },150); + listInventoryStructureParents(kind, parentSearch) + .then((loaded) => { + setParents(loaded); + if (contextParentId && loaded.some((item) => item.id === contextParentId)) setParentId(contextParentId); + }) + .catch((requestError) => setError(errorMessage(requestError))); + }, 150); return () => window.clearTimeout(timer); - }, [kind,parentSearch,contextParentId,requiresParent]); + }, [kind, parentSearch, contextParentId, requiresParent]); - const selectedParent = parents.find((item) => item.id===parentId) ?? null; + useEffect(() => { + if (kind !== 'YACIMIENTO' || !parentId) { + setAreaCompanies([]); + setOperatorCompanyId(''); + return; + } + setOperatorCompanyId(''); + listCompaniesForInventoryArea(parentId) + .then(setAreaCompanies) + .catch((requestError) => setError(errorMessage(requestError))); + }, [kind, parentId]); + + const selectedParent = parents.find((item) => item.id === parentId) ?? null; const families = useMemo(() => { if (!options) return [] as InventoryFamily[]; - if (kind==='INSTALACION') return options.installationFamilies; - if (kind==='SUBINSTALACION') { - const parentFamilyId=selectedParent?.inventoryFamily?.id; - return parentFamilyId ? options.subinstallationFamilies.filter((item)=>item.parentFamilyIds.includes(parentFamilyId)) : []; + if (kind === 'INSTALACION') return options.installationFamilies; + if (kind === 'SUBINSTALACION') { + const parentFamilyId = selectedParent?.inventoryFamily?.id; + return parentFamilyId + ? options.subinstallationFamilies.filter((item) => item.parentFamilyIds.includes(parentFamilyId)) + : []; } return []; - },[options,kind,selectedParent]); - const selectedFamily=families.find((item)=>item.id===familyId) ?? null; + }, [options, kind, selectedParent]); - useEffect(() => { - if (!familyId) { setFamilyFindings(null); return; } - getInventoryFamilyFindings(familyId).then(setFamilyFindings).catch((requestError)=>setError(errorMessage(requestError))); - },[familyId]); useEffect(() => { if (!requiresFamily) setFamilyId(''); - if (kind==='SUBINSTALACION' && familyId && !families.some((item)=>item.id===familyId)) setFamilyId(''); - },[kind,requiresFamily,families,familyId]); - useEffect(() => { - if (!requiresYacimientoContext) { setOperatorCompanyId(''); setConcessionTypeId(''); } - },[requiresYacimientoContext]); + if (kind === 'SUBINSTALACION' && familyId && !families.some((item) => item.id === familyId)) setFamilyId(''); + }, [kind, requiresFamily, families, familyId]); - const changeKind=(next:InventoryStructureKind) => { - setKind(next); setParentId(''); setParentSearch(''); setFamilyId(''); setFamilyFindings(null); - setOperatorCompanyId(''); setConcessionTypeId(''); setError(''); + const changeKind = (next: InventoryStructureKind) => { + setKind(next); + setParentId(''); + setParentSearch(''); + setFamilyId(''); + setAreaCompanies([]); + setOperatorCompanyId(''); + setError(''); }; - const save=async(event:FormEvent) => { + + const save = async (event: FormEvent) => { event.preventDefault(); - if (requiresParent && !parentId) { setError(`Seleccioná ${parentLabel}.`); return; } - if (requiresYacimientoContext && !operatorCompanyId) { setError('Seleccioná la Empresa relacionada del Yacimiento.'); return; } - if (requiresYacimientoContext && !concessionTypeId) { setError('Seleccioná el Tipo de concesión del Yacimiento.'); return; } - if (requiresFamily && !familyId) { setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); return; } - setSaving(true); setError(''); + if (requiresParent && !parentId) { + setError(`Seleccioná ${parentLabel}.`); + return; + } + if (requiresCompany && !operatorCompanyId) { + setError('Seleccioná la Empresa que opera este Yacimiento.'); + return; + } + if (requiresFamily && !familyId) { + setError(`Seleccioná el tipo de ${kindLabel(kind).toLowerCase()}.`); + return; + } + + setSaving(true); + setError(''); try { - const created=await createInventoryStructure({ - kind,name:name.trim(),parentId:parentId || null,familyId:familyId || null, - operatorCompanyId:operatorCompanyId || null,concessionTypeId:concessionTypeId || null, - code:code.trim() || null,commonName:commonName.trim() || null,description:description.trim() || null, + const created = await createInventoryStructure({ + kind, + name: name.trim(), + parentId: parentId || null, + familyId: familyId || null, + operatorCompanyId: operatorCompanyId || null, + code: code.trim() || null, }); - navigate(`/inventarios/${created.id}`,{replace:true}); - } catch(requestError) { setError(errorMessage(requestError)); } - finally { setSaving(false); } + navigate(`/inventarios/${created.id}`, { replace: true }); + } catch (requestError) { + setError(errorMessage(requestError)); + } finally { + setSaving(false); + } }; if (loading) return ; + + const disableSave = saving + || !name.trim() + || (requiresParent && !parentId) + || (requiresCompany && !operatorCompanyId) + || (requiresFamily && !familyId); + return
- -
CARGA MANUAL

Nuevo registro

Modelo fijo: Departamento → Área → Yacimiento → Instalación → Subinstalación. La Empresa y el Tipo de concesión pertenecen al Yacimiento.

+ + +
+
+ ALTA RÁPIDA +

Agregar al Inventario

+

Elegí qué querés crear, dónde está y completá sólo los datos necesarios.

+
+
+ {error && {error}} +
-

1. ¿Qué querés crear?

Área sólo depende de Departamento. Yacimiento define Área, Empresa relacionada y Tipo de concesión.

- {requiresParent &&

2. Ubicación

Elegí el {parentLabel.toLowerCase()} concreto.

} - {requiresYacimientoContext &&

3. Contexto del Yacimiento

Estas relaciones pertenecen directamente al Yacimiento y no al Área.

} - {requiresFamily &&

3. Clasificación técnica

Define los datos técnicos y Hallazgos aplicables. En Subinstalaciones sólo aparecen clasificaciones compatibles con la Instalación elegida.

{kind==='SUBINSTALACION' && !selectedParent?.inventoryFamily ? La Instalación elegida todavía no tiene clasificación técnica. : }{kind==='SUBINSTALACION' && selectedParent?.inventoryFamily && families.length===0 && No hay tipos de Subinstalación compatibles con {selectedParent.inventoryFamily.name}.}{selectedFamily &&
{selectedFamily.name}{familyFindings ? `${familyFindings.count} Hallazgo${familyFindings.count===1?'':'s'} asociado${familyFindings.count===1?'':'s'}` : 'Cargando Hallazgos asociados…'}{familyFindings && familyFindings.items.length>0 &&
    {familyFindings.items.slice(0,6).map((item)=>
  • {item.title}
  • )}{familyFindings.items.length>6 &&
  • + {familyFindings.items.length-6} más
  • }
}
}
} -

{requiresYacimientoContext ? '4' : requiresFamily ? '4' : requiresParent ? '3' : '2'}. Nombre

El nombre es el dato propio principal de este nivel. Los datos técnicos se completan después, cuando corresponda.

Datos opcionales