Compare commits

...
Author SHA1 Message Date
admin a5d8022720 fix(f5): make catalog SQL text parameter types explicit 2026-09-08 23:12:47 -03:00
admin bfa9110c77 fix(f5): type relation backup markers explicitly 2026-09-08 23:07:25 -03:00
admin 915a2ee299 ci(f5): include canonical hierarchy migration in rollback gate 2026-09-08 23:02:37 -03:00
admin 91884b567e fix(f5): guarantee canonical inventory hierarchy before territory preload 2026-09-08 23:02:08 -03:00
admin 9dad6888b4 fix(f5): make territory SQL parameter types explicit 2026-09-08 22:56:13 -03:00
admin 1afef0f1f3 ci(f5): bypass irreversible historical reset in clean rehearsal 2026-09-08 22:48:08 -03:00
admin 3cfd3090aa ci(f5): bootstrap historical admin prerequisite in clean migration rehearsal 2026-09-08 22:44:30 -03:00
admin 8e2ed6e38a test(f5): model normalized yacimiento collision explicitly 2026-09-08 22:38:40 -03:00
admin e92b1623c8 test(f5): distinguish literal and normalized yacimiento counts 2026-09-08 22:35:53 -03:00
admin 78e1d5cbf6 test(f5): make merge compatibility area-based 2026-09-08 22:35:26 -03:00
admin bd110b22f3 test(f5): align operator lifecycle with area-owned inventory 2026-09-08 22:35:15 -03:00
admin a53306b9d7 test(f5): align operational navigation contract 2026-09-08 22:35:05 -03:00
admin ec535233fa ci: prove F5 migrations revert and reapply cleanly 2026-09-08 22:08:54 -03:00
admin 35fa968128 ci: rehearse all migrations on clean PostGIS before merge 2026-09-08 22:08:08 -03:00
admin ef781abaca test(dashboard): route F5 summary query mocks explicitly 2026-09-08 22:05:58 -03:00
admin 3752b78f58 test(dashboard): expect inspector activity and act follow-up metrics 2026-09-08 22:02:39 -03:00
admin 923258f5bc chore(release): expose F5 WEB phase 2026-09-08 22:00:30 -03:00
admin 00065d55a6 chore(release): align WEB package with F5 inventory cut 2026-09-08 22:00:21 -03:00
admin e92ff01981 test(release): expect F5 health metadata 2026-09-08 22:00:09 -03:00
admin 7a66d67296 chore(release): bump API for F5 inventory model 2026-09-08 21:58:20 -03:00
admin 99ea1614ff chore(release): identify F5 inventory model 2026-09-08 21:58:07 -03:00
admin 8a9f81b8de fix(web): redefine inventory configuration around hierarchy classifications and columns 2026-09-08 21:56:30 -03:00
admin 741b2f2d8e fix(web): configure finding applicability by inventory classification 2026-09-08 21:54:29 -03:00
admin afa11918a6 feat(web): add inventory classification administration API 2026-09-08 21:53:59 -03:00
admin e2bb122d9e feat(inventory): expose family classification administration 2026-09-08 21:53:31 -03:00
admin 21c51bf672 feat(inventory): administer installation and subinstallation classifications 2026-09-08 21:53:17 -03:00
admin 90a85a7c1f feat(inventory): add family administration DTOs 2026-09-08 21:52:25 -03:00
admin fde562e2b4 test(inventory): lock F5 authoritative model and merge invariants 2026-09-08 21:51:04 -03:00
admin 5e26b4a899 fix(inventory): merge by physical area and preserve sealed documents 2026-09-08 21:50:21 -03:00
admin b4d81cff68 feat(web): create companies independently from physical inventory hierarchy 2026-09-08 21:47:12 -03:00
admin 53c7ca45f1 feat(web): expose independent company master in inventory setup 2026-09-08 21:46:29 -03:00
admin 0e320a9395 fix(inventory): make guided creation area-owned and support companies 2026-09-08 21:46:12 -03:00
admin 791137eb65 feat(inventory): allow independent company master creation 2026-09-08 21:45:23 -03:00
admin bb4a4eeb17 fix(inventory): make authoritative territory preload safely reversible 2026-09-08 21:40:41 -03:00
admin 39ca9dc35f fix(inventory): harden authoritative catalog migration checks 2026-09-08 21:38:00 -03:00
admin c651ef9616 fix(inventory): preserve custom catalog and apply authoritative universal findings 2026-09-08 21:34:52 -03:00
admin a3dd705190 fix(inventory): resolve field inventory by area hierarchy, not company ownership 2026-09-08 21:31:37 -03:00
admin f6564194b6 fix(inventory): treat company on asset as non-owning creation snapshot 2026-09-08 21:30:21 -03:00
admin dcb6b9fdce fix(inventory): apply area-owned operational guard on F5 up 2026-09-08 21:26:07 -03:00
admin 4e93b29edf fix(web): load F5 flow styles 2026-09-08 20:06:14 -03:00
admin ccc5226f72 fix(web): style act follow-up tabs and F5 inventory context 2026-09-08 20:06:05 -03:00
admin 4bc54f67fe fix(web): show only real inventory instances 2026-09-08 20:05:04 -03:00
admin 3f0148e4b7 refactor(web): make inventory hierarchy area-owned 2026-09-08 20:04:35 -03:00
admin 1a6f6f5c68 feat(web): add real inventory list client 2026-09-08 20:03:32 -03:00
admin 06aaacfd3f feat(inventory): expose real inventory list 2026-09-08 20:03:13 -03:00
admin 0548115516 feat(inventory): add real inventory list endpoint logic 2026-09-08 20:02:59 -03:00
admin 7945d1a729 feat(inventory): extend inventory browser filters 2026-09-08 20:02:24 -03:00
admin 8ea8a35829 feat(web): add inventory browser API client 2026-09-08 20:01:55 -03:00
admin 31a3a207d6 fix(inventory): make operator relation temporal and non-owning 2026-09-08 20:01:17 -03:00
admin bd6d91d7ac refactor(inventory): remove function catalog and add inventory browser 2026-09-08 20:00:09 -03:00
admin 598c987031 feat(inventory): expose area-owned inventory browser 2026-09-08 19:59:50 -03:00
admin 84217b67d8 feat(inventory): add area-owned inventory browser 2026-09-08 19:59:39 -03:00
admin 9e29c9d69c feat(inventory): add inventory browser query contract 2026-09-08 19:59:18 -03:00
admin 4bbe32ba93 feat(inventory): add inventory-only tree filter 2026-09-08 19:58:18 -03:00
admin ff518ee916 feat(inventory): add inventory-only asset filter 2026-09-08 19:57:58 -03:00
admin f4cd931d51 fix(inventory): preserve non-hierarchical operator snapshot compatibility 2026-09-08 19:57:18 -03:00
admin 436b57a3da fix(findings): use only authoritative F5 family catalog 2026-09-08 19:55:04 -03:00
admin 60acadd24e feat(inventory): preload authoritative technical catalog 2026-09-08 19:54:44 -03:00
admin 218f792f13 feat(inventory): preload authoritative territory model 2026-09-08 19:54:00 -03:00
admin 91e346d4c4 feat(inventory): add authoritative F5 source snapshot 2026-09-08 19:46:59 -03:00
admin afbece7d70 fix(inventory): count only installation and subinstallation instances 2026-09-08 19:07:50 -03:00
admin 88d43b7414 feat(inventory): classify future concrete instances at database level 2026-09-08 18:23:16 -03:00
admin 6b5b522be1 fix(inventory): keep field yacimientos structural 2026-09-08 18:22:47 -03:00
admin 637e9167b4 feat(inventory): map physical instance flag on asset entity 2026-09-08 18:22:15 -03:00
admin e082f5d062 feat(inventory): mark field-created records as real instances 2026-09-08 18:08:19 -03:00
admin ead1beb9d7 fix(web): clarify dashboard act and report concepts 2026-09-08 18:07:55 -03:00
admin 8389991b2c fix(web): align dashboard summary contract 2026-09-08 18:07:26 -03:00
admin 79258836b1 fix(dashboard): align inventory and act follow-up metrics 2026-09-08 18:07:17 -03:00
admin 11052c83aa feat(inventory): distinguish physical instances from structural records 2026-09-08 18:06:11 -03:00
admin a27f17d642 fix(web): remove redundant inspections lifecycle legend 2026-09-08 18:05:44 -03:00
admin 9083883bf2 fix(web): remove legacy imports and function catalog routes 2026-09-08 18:05:16 -03:00
admin fffd1ddcc2 fix(web): simplify inventory administration menu 2026-09-08 18:04:55 -03:00
49 changed files with 3988 additions and 669 deletions
+146
View File
@@ -69,6 +69,152 @@ jobs:
grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh
- name: Validate Compose
run: docker compose --env-file .env.example config >/dev/null
- name: Rehearse migrations on clean PostGIS
run: |
set -Eeuo pipefail
cleanup() {
docker compose --env-file .env.example --profile tools down -v --remove-orphans >/dev/null 2>&1 || true
}
trap cleanup EXIT
cleanup
docker compose --env-file .env.example up -d db
# The historical production reset is a one-shot operational migration,
# not a bootstrap migration: it requires production data/configuration
# that cannot exist at its timestamp in a database rebuilt from zero.
# Prove the clean chain reaches that exact guard, then mark only that
# one-shot migration as already applied and continue the reproducible
# schema chain. The historical migration itself remains untouched.
bootstrap_log="$(mktemp)"
set +e
docker compose --env-file .env.example --profile tools run --build --rm migrate 2>&1 | tee "$bootstrap_log"
bootstrap_status=${PIPESTATUS[0]}
set -e
if [ "$bootstrap_status" -eq 0 ]; then
echo "ERROR: clean migration rehearsal unexpectedly passed the historical production reset." >&2
exit 1
fi
grep -Fq 'Production reset aborted: expected exactly one username admin, found 0' "$bootstrap_log" || {
echo "ERROR: migration rehearsal failed before the expected historical production-reset guard." >&2
exit 1
}
rm -f "$bootstrap_log"
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE
reset_rows integer;
BEGIN
SELECT COUNT(*) INTO reset_rows
FROM typeorm_migrations
WHERE name = 'ResetProductionOperationalData1788652800000';
IF reset_rows <> 0 THEN
RAISE EXCEPTION 'CI one-shot bypass expected reset migration to be pending, found % rows', reset_rows;
END IF;
INSERT INTO typeorm_migrations ("timestamp", name)
VALUES (1788652800000, 'ResetProductionOperationalData1788652800000');
END $$;
SQL
docker compose --env-file .env.example --profile tools run --rm migrate
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE
f5_migrations integer;
real_inventory integer;
source_areas integer;
source_yacimientos integer;
source_installations integer;
source_subinstallations integer;
source_findings integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
WHERE name IN (
'F5InventoryPhysicalInstance1790087100000',
'F5CanonicalInventoryHierarchy1790087150000',
'F5AuthoritativeTerritory1790087200000',
'F5OperationalContextCompatibility1790087250000',
'F5AuthoritativeInventoryCatalog1790087300000'
);
IF f5_migrations <> 5 THEN
RAISE EXCEPTION 'Expected 5 F5 migrations, got %', f5_migrations;
END IF;
SELECT COUNT(*) INTO real_inventory
FROM assets WHERE is_inventory_instance=true AND information_status<>'INACTIVE';
IF real_inventory <> 0 THEN
RAISE EXCEPTION 'Fresh F5 database must start with 0 real Inventory instances, got %', real_inventory;
END IF;
SELECT COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA'),
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')
INTO source_areas,source_yacimientos
FROM source_documents document
JOIN asset_source_documents link ON link.document_id=document.id
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE document.document_number='DH-F5-TERRITORY';
IF source_areas <> 64 OR source_yacimientos <> 230 THEN
RAISE EXCEPTION 'F5 territory preload mismatch: areas %, yacimientos %', source_areas,source_yacimientos;
END IF;
SELECT COUNT(*) FILTER (WHERE level='INSTALLATION'),
COUNT(*) FILTER (WHERE level='SUBINSTALLATION')
INTO source_installations,source_subinstallations
FROM inventory_families
WHERE is_active=true AND source_reference LIKE 'F5:final_modelov2.xlsx%';
IF source_installations <> 14 OR source_subinstallations <> 109 THEN
RAISE EXCEPTION 'F5 family preload mismatch: installations %, subinstallations %', source_installations,source_subinstallations;
END IF;
SELECT COUNT(*) INTO source_findings
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE lower(category.code)='f5model' AND item.is_active=true;
IF source_findings <> 177 THEN
RAISE EXCEPTION 'F5 finding preload mismatch: %', source_findings;
END IF;
END $$;
SQL
# Prove the five F5 migrations are actually reversible on a clean state.
for _ in 1 2 3 4 5; do
docker compose --env-file .env.example --profile tools run --rm migrate npm run migration:revert
done
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 <<'SQL'
DO $$
DECLARE f5_migrations integer; instance_column integer;
BEGIN
SELECT COUNT(*) INTO f5_migrations
FROM typeorm_migrations
WHERE name LIKE 'F5%1790087%';
IF f5_migrations <> 0 THEN
RAISE EXCEPTION 'F5 rollback left % migration rows behind', f5_migrations;
END IF;
SELECT COUNT(*) INTO instance_column
FROM information_schema.columns
WHERE table_schema='public' AND table_name='assets' AND column_name='is_inventory_instance';
IF instance_column <> 0 THEN
RAISE EXCEPTION 'F5 rollback left is_inventory_instance behind';
END IF;
END $$;
SQL
# Reapply them once more. Each F5 migration performs its own source/cardinality checks.
docker compose --env-file .env.example --profile tools run --rm migrate
docker compose --env-file .env.example exec -T db \
psql -v ON_ERROR_STOP=1 -U dhv2_owner -d dhv2 -Atc \
"SELECT CASE WHEN COUNT(*)=5 THEN 'F5_REAPPLY_OK' ELSE 'F5_REAPPLY_FAILED:'||COUNT(*) END FROM typeorm_migrations WHERE name IN ('F5InventoryPhysicalInstance1790087100000','F5CanonicalInventoryHierarchy1790087150000','F5AuthoritativeTerritory1790087200000','F5OperationalContextCompatibility1790087250000','F5AuthoritativeInventoryCatalog1790087300000');" \
| grep -Fx 'F5_REAPPLY_OK'
- name: VPS-equivalent isolated API preflight
run: |
set -Eeuo pipefail
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.25.0-1",
"version": "0.26.0-1",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -29,8 +29,8 @@ import { InventoryFamilyCatalogService } from './inventory-family-catalog.servic
import { FieldInventoryMergeController, InventoryMergeController } from './inventory-merge.controller';
import { InventoryMergeService } from './inventory-merge.service';
import { MergedInventoryDossierService } from './merged-inventory-dossier.service';
import { InventoryFunctionController } from './inventory-function.controller';
import { InventoryFunctionService } from './inventory-function.service';
import { InventoryBrowserController } from './inventory-browser.controller';
import { InventoryBrowserService } from './inventory-browser.service';
@Module({
imports: [AuditModule],
@@ -39,7 +39,7 @@ import { InventoryFunctionService } from './inventory-function.service';
AssetsController,
InventoryStructureController,
InventoryFamilyCatalogController,
InventoryFunctionController,
InventoryBrowserController,
InventoryMergeController,
FieldInventoryMergeController,
AssetGeometriesController,
@@ -56,7 +56,7 @@ import { InventoryFunctionService } from './inventory-function.service';
AssetsService,
InventoryStructureService,
InventoryFamilyCatalogService,
InventoryFunctionService,
InventoryBrowserService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
@@ -71,7 +71,6 @@ import { InventoryFunctionService } from './inventory-function.service';
exports: [
AssetHistoryService,
AssetsService,
InventoryFunctionService,
InventoryMergeService,
MergedInventoryDossierService,
AssetGeometriesService,
@@ -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
@@ -10,6 +10,7 @@ import {
} from 'class-validator';
export const INVENTORY_STRUCTURE_KINDS = [
'EMPRESA',
'AREA',
'YACIMIENTO',
'INSTALACION',
@@ -0,0 +1,63 @@
import { Transform, Type } from 'class-transformer';
import {
IsBoolean,
IsEnum,
IsInt,
IsOptional,
IsString,
IsUUID,
Max,
MaxLength,
Min,
} from 'class-validator';
import { AssetInformationStatus, AssetOperationalStatus } from '../../database/entities';
export class InventoryBrowserQueryDto {
@IsOptional()
@IsString()
@MaxLength(200)
search?: string;
@IsOptional()
@IsUUID('4')
typeId?: string;
@IsOptional()
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@IsOptional()
@IsUUID('4')
operationalAreaId?: string;
@IsOptional()
@IsUUID('4')
operatorCompanyId?: string;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
needsValidation?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
pageSize = 25;
}
@@ -0,0 +1,71 @@
import { Transform } from 'class-transformer';
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
IsOptional,
IsString,
IsUUID,
MaxLength,
MinLength,
} from 'class-validator';
export class CreateInventoryFamilyDto {
@IsIn(['INSTALLATION', 'SUBINSTALLATION'])
level!: 'INSTALLATION' | 'SUBINSTALLATION';
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name!: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
}
export class UpdateInventoryFamilyDto {
@IsOptional()
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(1)
@MaxLength(240)
name?: string;
@IsOptional()
@IsUUID('4')
parentFamilyId?: string | null;
@IsOptional()
@IsArray()
@ArrayMaxSize(100)
@IsString({ each: true })
@MaxLength(160, { each: true })
informationLabels?: string[];
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class ReplaceInventoryFamilyFindingsDto {
@IsArray()
@ArrayMaxSize(2000)
@IsUUID('4', { each: true })
itemIds!: string[];
@Transform(({ value }) => typeof value === 'string' ? value.trim() : value)
@IsString()
@MinLength(5)
@MaxLength(2000)
reason!: string;
}
@@ -37,4 +37,9 @@ export class ListAssetTreeQueryDto {
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
}
@@ -39,7 +39,6 @@ export class ListAssetsQueryDto {
@IsEnum(AssetInformationStatus)
status?: AssetInformationStatus;
@IsOptional()
@IsEnum(AssetOperationalStatus)
operationalStatus?: AssetOperationalStatus;
@@ -54,6 +53,11 @@ export class ListAssetsQueryDto {
@IsBoolean()
hasGeometry?: boolean;
@IsOptional()
@Transform(({ value }) => value === 'true' ? true : value === 'false' ? false : value)
@IsBoolean()
inventoryOnly?: boolean;
@IsOptional()
@IsUUID('4')
parentId?: string;
@@ -0,0 +1,28 @@
import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
import { InventoryBrowserService } from './inventory-browser.service';
@Controller('inventory-browser')
@RequirePermissions('assets.read')
export class InventoryBrowserController {
constructor(private readonly inventoryBrowser: InventoryBrowserService) {}
@Get('items')
items(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.items(query);
}
@Get('areas')
areas(@Query() query: InventoryBrowserQueryDto) {
return this.inventoryBrowser.areas(query);
}
@Get(':parentId/children')
children(
@Param('parentId', new ParseUUIDPipe({ version: '4' })) parentId: string,
@Query() query: InventoryBrowserQueryDto,
) {
return this.inventoryBrowser.children(parentId, query);
}
}
@@ -0,0 +1,283 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { InventoryBrowserQueryDto } from './dto/inventory-browser-query.dto';
type ParentContext = {
id: string;
code: string;
name: string;
typeCode: string;
};
@Injectable()
export class InventoryBrowserService {
constructor(private readonly dataSource: DataSource) {}
async items(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
'asset.is_inventory_instance=true',
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => {
params.push(value);
return `$${params.length}`;
};
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
if (query.typeId) conditions.push(`asset.asset_type_id=${add(query.typeId)}::uuid`);
if (query.status) conditions.push(`asset.information_status=${add(query.status)}::asset_information_status`);
if (query.operationalStatus) conditions.push(`asset.operational_status=${add(query.operationalStatus)}::asset_operational_status`);
if (query.needsValidation === true) conditions.push("asset.information_status NOT IN ('VALIDATED','INACTIVE')");
if (query.needsValidation === false) conditions.push("asset.information_status='VALIDATED'");
if (query.hasGeometry === true) conditions.push('EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.hasGeometry === false) conditions.push('NOT EXISTS (SELECT 1 FROM asset_geometries geometry_filter WHERE geometry_filter.asset_id=asset.id)');
if (query.operationalAreaId) conditions.push(`asset.operational_area_id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) {
const company = add(query.operatorCompanyId);
conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=asset.operational_area_id
AND relation.company_id=${company}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)`);
}
const where = conditions.join(' AND ');
const [countRow] = (await this.dataSource.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE ${where}
`,params)) as Array<{total:number}>;
const total=Number(countRow?.total ?? 0);
const offset=(query.page-1)*query.pageSize;
params.push(query.pageSize);
const limit=`$${params.length}`;
params.push(offset);
const offsetParam=`$${params.length}`;
const data=await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
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",
(
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=asset.operational_area_id
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
LIMIT 1
) AS "operatorCompany",
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
0::integer AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
CASE WHEN geometry_type.type IS NULL THEN NULL ELSE geometry_type.type END AS "geometryType",
(SELECT COUNT(*)::integer FROM asset_media media WHERE media.asset_id=asset.id AND media.deleted_at IS NULL) AS "mediaCount",
asset.data_origin AS "dataOrigin",
(asset.provenance_verified_at IS NOT NULL) AS "provenanceVerified",
asset.current_version AS "currentVersion",
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 LATERAL (
SELECT ST_GeometryType(geometry.geometry)::text AS type
FROM asset_geometries geometry
WHERE geometry.asset_id=asset.id
ORDER BY geometry.updated_at DESC
LIMIT 1
) geometry_type ON true
WHERE ${where}
ORDER BY asset.name,asset.code
LIMIT ${limit} OFFSET ${offsetParam}
`,params);
return {
data,
meta:{
page:query.page,
pageSize:query.pageSize,
total,
totalPages:total===0 ? 0 : Math.ceil(total/query.pageSize),
},
};
}
async areas(query: InventoryBrowserQueryDto) {
const params: unknown[] = [];
const conditions = [
"type.operational_role='AREA'",
"area.information_status<>'INACTIVE'",
'type.is_active=true',
];
const add = (value: unknown): string => {
params.push(value);
return `$${params.length}`;
};
if (query.search?.trim()) {
const p = add(`%${query.search.trim()}%`);
conditions.push(`(area.code ILIKE ${p} OR area.name ILIKE ${p} OR COALESCE(area.common_name,'') ILIKE ${p})`);
}
if (query.operationalAreaId) conditions.push(`area.id=${add(query.operationalAreaId)}::uuid`);
if (query.operatorCompanyId) {
const p = add(query.operatorCompanyId);
conditions.push(`EXISTS (
SELECT 1 FROM area_company_relations relation
WHERE relation.area_id=area.id
AND relation.company_id=${p}::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
)`);
}
const data = await this.dataSource.query(`
SELECT
area.id,area.code,area.name,area.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
area.information_status AS "informationStatus",
area.operational_status AS "operationalStatus",
(
SELECT JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name)
FROM area_company_relations relation
JOIN assets company ON company.id=relation.company_id
WHERE relation.area_id=area.id
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
ORDER BY relation.valid_from DESC,relation.created_at DESC
LIMIT 1
) AS "currentOperator",
(
SELECT COUNT(*)::integer
FROM assets yacimiento
JOIN asset_types ytype ON ytype.id=yacimiento.asset_type_id
WHERE yacimiento.parent_id=area.id
AND lower(ytype.code)='yacimiento'
AND yacimiento.information_status<>'INACTIVE'
) AS "yacimientoCount",
(
SELECT COUNT(*)::integer
FROM assets inventory
WHERE inventory.is_inventory_instance=true
AND inventory.information_status<>'INACTIVE'
AND inventory.operational_area_id=area.id
) AS "inventoryCount"
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
WHERE ${conditions.join(' AND ')}
ORDER BY area.name,area.code
`, params);
return { data, meta: { count: data.length } };
}
async children(parentId: string, query: InventoryBrowserQueryDto) {
const parent = await this.parent(parentId);
const allowedChildType = this.allowedChildType(parent.typeCode);
if (!allowedChildType) return { parent, data: [], meta: { count: 0, hasMore: false } };
const params: unknown[] = [parentId, allowedChildType];
const conditions = [
'asset.parent_id=$1::uuid',
'lower(type.code)=lower($2)',
"asset.information_status<>'INACTIVE'",
'type.is_active=true',
];
if (allowedChildType === 'instalacion' || allowedChildType === 'subinstalacion') {
conditions.push('asset.is_inventory_instance=true');
}
if (query.search?.trim()) {
params.push(`%${query.search.trim()}%`);
const p = `$${params.length}`;
conditions.push(`(asset.code ILIKE ${p} OR asset.name ILIKE ${p} OR COALESCE(asset.common_name,'') ILIKE ${p})`);
}
params.push(201);
const limit = `$${params.length}`;
const rows = await this.dataSource.query(`
SELECT
asset.id,asset.code,asset.name,asset.common_name AS "commonName",
JSONB_BUILD_OBJECT('id',type.id,'code',type.code,'name',type.name) AS type,
CASE WHEN parent_asset.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',parent_asset.id,'code',parent_asset.code,'name',parent_asset.name
) END AS parent,
asset.information_status AS "informationStatus",
asset.operational_status AS "operationalStatus",
asset.is_inventory_instance AS "isInventoryInstance",
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",
(
SELECT COUNT(*)::integer
FROM assets child
WHERE child.parent_id=asset.id
AND child.information_status<>'INACTIVE'
AND (
lower(type.code)='area'
OR child.is_inventory_instance=true
OR EXISTS (
SELECT 1 FROM asset_types child_type
WHERE child_type.id=child.asset_type_id AND lower(child_type.code)='yacimiento'
)
)
) AS "childrenCount",
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id) AS "hasGeometry",
asset.updated_at AS "updatedAt"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN assets parent_asset ON parent_asset.id=asset.parent_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE ${conditions.join(' AND ')}
ORDER BY asset.name,asset.code
LIMIT ${limit}
`, params);
const hasMore = rows.length > 200;
const data = hasMore ? rows.slice(0,200) : rows;
return { parent, data, meta: { count: data.length, hasMore } };
}
private async parent(parentId: string): Promise<ParentContext> {
const [parent] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id=$1::uuid AND asset.information_status<>'INACTIVE'
`,[parentId]) as ParentContext[];
if (!parent) {
throw new NotFoundException({ code:'INVENTORY_BROWSER_PARENT_NOT_FOUND',message:'El nivel de Inventario no existe' });
}
if (!['area','yacimiento','instalacion','subinstalacion'].includes(parent.typeCode.toLowerCase())) {
throw new BadRequestException({
code:'INVENTORY_BROWSER_PARENT_TYPE_INVALID',
message:'La navegación de Inventarios admite Área → Yacimiento → Instalación → Subinstalación',
});
}
return parent;
}
private allowedChildType(typeCode: string): string | null {
switch (typeCode.toLowerCase()) {
case 'area': return 'yacimiento';
case 'yacimiento': return 'instalacion';
case 'instalacion': return 'subinstalacion';
default: return null;
}
}
}
@@ -1,11 +1,66 @@
import { Controller, Get, Param, ParseUUIDPipe } from '@nestjs/common';
import {
Body,
Controller,
Get,
Param,
ParseUUIDPipe,
Patch,
Post,
Put,
Req,
} from '@nestjs/common';
import { CurrentAuth } from '../auth/decorators/current-auth.decorator';
import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
import { InventoryFamilyCatalogService } from './inventory-family-catalog.service';
@Controller('inventory-families')
export class InventoryFamilyCatalogController {
constructor(private readonly families: InventoryFamilyCatalogService) {}
@Get('admin')
@RequirePermissions('asset_types.read')
admin() {
return this.families.listAdmin();
}
@Post()
@RequirePermissions('asset_types.manage')
create(
@Body() dto: CreateInventoryFamilyDto,
@CurrentAuth() principal: AuthPrincipal,
@Req() request: RequestWithContext,
) {
return this.families.create(dto,principal,request);
}
@Patch(':familyId')
@RequirePermissions('asset_types.manage')
update(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:UpdateInventoryFamilyDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.update(familyId,dto,principal,request);
}
@Put(':familyId/findings')
@RequirePermissions('finding_catalog.manage')
replaceFindings(
@Param('familyId',new ParseUUIDPipe({version:'4'})) familyId:string,
@Body() dto:ReplaceInventoryFamilyFindingsDto,
@CurrentAuth() principal:AuthPrincipal,
@Req() request:RequestWithContext,
) {
return this.families.replaceFindings(familyId,dto,principal,request);
}
@Get(':familyId/findings')
@RequirePermissions('assets.read')
findings(@Param('familyId', new ParseUUIDPipe({ version: '4' })) familyId: string) {
@@ -1,28 +1,71 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import { randomUUID } from 'node:crypto';
import {
BadRequestException,
ConflictException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { DataSource, EntityManager } from 'typeorm';
import { administrationAuditContext } from '../administration/common/administration-audit';
import { AuditService } from '../audit/audit.service';
import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context';
import { AuditAction } from '../database/entities';
import type {
CreateInventoryFamilyDto,
ReplaceInventoryFamilyFindingsDto,
UpdateInventoryFamilyDto,
} from './dto/inventory-family-admin.dto';
type FamilyRow = {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
informationLabels: string[];
sourceReference: string | null;
isActive: boolean;
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
assetCount: number;
findingCount: number;
findingItemIds: string[];
};
@Injectable()
export class InventoryFamilyCatalogService {
constructor(private readonly dataSource: DataSource) {}
constructor(
private readonly dataSource: DataSource,
private readonly audit: AuditService,
) {}
async listAdmin() {
const data = await this.dataSource.query(`
SELECT
family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",
family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((
SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY item.title,item.id)
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
WHERE mapping.inventory_family_id=family.id
),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
ORDER BY family.level,family.is_active DESC,
COALESCE(parent.name,''),family.name,family.code
`) as FamilyRow[];
return { data };
}
async findings(familyId: string) {
const [family] = await this.dataSource.query(`
SELECT id,code,name,level,information_labels AS "informationLabels"
FROM inventory_families
WHERE id=$1::uuid AND is_active=true
`, [familyId]) as Array<{
id: string;
code: string;
name: string;
level: string;
informationLabels: string[];
}>;
if (!family) {
throw new NotFoundException({
code: 'INVENTORY_FAMILY_NOT_FOUND',
message: 'La familia técnica no existe',
});
}
const family = await this.family(familyId, false);
const items = await this.dataSource.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
@@ -36,4 +79,221 @@ export class InventoryFamilyCatalogService {
`, [familyId]);
return { family, items, count: items.length };
}
async create(
dto: CreateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const parentId = await this.validateParent(manager,dto.level,dto.parentFamilyId ?? null,null);
const code = `CUSTOM-${dto.level === 'INSTALLATION' ? 'I' : 'S'}-${randomUUID().slice(0,8).toUpperCase()}`;
const [inserted] = (await manager.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,$4::jsonb,'MANUAL:F5',true)
RETURNING id
`,[code,dto.name,dto.level,JSON.stringify(this.cleanLabels(dto.informationLabels ?? []))])) as Array<{id:string}>;
if (!inserted) throw new Error('No se pudo crear la clasificación de Inventario');
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[inserted.id,parentId]);
}
const created = await this.family(inserted.id,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: inserted.id,
afterData: created as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_CREATED', source:'MANUAL:F5' },
},manager);
return created;
});
}
async update(
familyId: string,
dto: UpdateInventoryFamilyDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const before = await this.family(familyId,false,manager,true);
const nextParentId = dto.parentFamilyId === undefined
? before.parentFamilyId
: dto.parentFamilyId;
const parentId = await this.validateParent(manager,before.level,nextParentId ?? null,familyId);
await manager.query(`
UPDATE inventory_families SET
name=COALESCE($2::varchar,name),
information_labels=COALESCE($3::jsonb,information_labels),
is_active=COALESCE($4::boolean,is_active),
updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
familyId,
dto.name ?? null,
dto.informationLabels === undefined ? null : JSON.stringify(this.cleanLabels(dto.informationLabels)),
dto.isActive ?? null,
]);
if (before.level==='SUBINSTALLATION') {
await manager.query(`DELETE FROM inventory_family_parent_rules WHERE child_family_id=$1::uuid`,[familyId]);
if (parentId) {
await manager.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
`,[familyId,parentId]);
}
}
const after = await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType: 'inventory_family',
entityId: familyId,
beforeData: before as unknown as Record<string,unknown>,
afterData: after as unknown as Record<string,unknown>,
metadata: { operation:'INVENTORY_FAMILY_UPDATED' },
},manager);
return after;
});
}
async replaceFindings(
familyId: string,
dto: ReplaceInventoryFamilyFindingsDto,
principal: AuthPrincipal,
request: RequestWithContext,
) {
return this.dataSource.transaction(async (manager) => {
const family = await this.family(familyId,false,manager,true);
const uniqueIds=[...new Set(dto.itemIds)];
if (uniqueIds.length) {
const [count] = (await manager.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=ANY($1::uuid[]) AND item.is_active=true AND category.is_active=true
`,[uniqueIds])) as Array<{total:number}>;
if (Number(count?.total ?? 0)!==uniqueIds.length) {
throw new BadRequestException({
code:'INVENTORY_FAMILY_FINDING_INVALID',
message:'Uno o más Hallazgos elegidos no están activos en el catálogo',
});
}
}
const beforeIds=family.findingItemIds;
await manager.query(`DELETE FROM finding_catalog_item_inventory_families WHERE inventory_family_id=$1::uuid`,[familyId]);
if (uniqueIds.length) {
await manager.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
SELECT item_id,$2::uuid FROM UNNEST($1::uuid[]) AS selected(item_id)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[uniqueIds,familyId]);
}
const after=await this.family(familyId,false,manager);
await this.audit.record({
...administrationAuditContext(principal,request),
action: AuditAction.ASSET_UPDATED,
entityType:'inventory_family_findings',
entityId:familyId,
beforeData:{ itemIds:beforeIds },
afterData:{ itemIds:after.findingItemIds },
metadata:{ operation:'INVENTORY_FAMILY_FINDINGS_REPLACED', reason:dto.reason },
},manager);
return this.findingsWithManager(manager,familyId);
});
}
private async findingsWithManager(manager:EntityManager,familyId:string) {
const family=await this.family(familyId,false,manager);
const items=await manager.query(`
SELECT item.id,item.code,item.source_number AS "sourceNumber",item.title,
item.legal_basis AS "legalBasis",item.glossary,item.suggested_severity AS "suggestedSeverity",
category.id AS "categoryId",category.code AS "categoryCode",category.name AS "categoryName"
FROM finding_catalog_item_inventory_families mapping
JOIN finding_catalog_items item ON item.id=mapping.catalog_item_id
JOIN finding_categories category ON category.id=item.category_id
WHERE mapping.inventory_family_id=$1::uuid
AND item.is_active=true AND category.is_active=true
ORDER BY category.sort_order,item.source_number,item.title
`,[familyId]);
return {family,items,count:items.length};
}
private async family(
familyId:string,
activeOnly:boolean,
manager:EntityManager=this.dataSource.manager,
lock=false,
):Promise<FamilyRow> {
const rows=(await manager.query(`
SELECT family.id,family.code,family.name,family.level,
family.information_labels AS "informationLabels",
family.source_reference AS "sourceReference",family.is_active AS "isActive",
parent.id AS "parentFamilyId",parent.code AS "parentFamilyCode",parent.name AS "parentFamilyName",
(SELECT COUNT(*)::integer FROM assets asset WHERE asset.inventory_family_id=family.id) AS "assetCount",
(SELECT COUNT(*)::integer FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id) AS "findingCount",
COALESCE((SELECT JSONB_AGG(mapping.catalog_item_id ORDER BY mapping.catalog_item_id)
FROM finding_catalog_item_inventory_families mapping WHERE mapping.inventory_family_id=family.id),'[]'::jsonb) AS "findingItemIds"
FROM inventory_families family
LEFT JOIN inventory_family_parent_rules rule ON rule.child_family_id=family.id
LEFT JOIN inventory_families parent ON parent.id=rule.parent_family_id
WHERE family.id=$1::uuid ${activeOnly ? 'AND family.is_active=true' : ''}
${lock ? 'FOR UPDATE OF family' : ''}
`,[familyId])) as FamilyRow[];
if (!rows[0]) throw new NotFoundException({
code:'INVENTORY_FAMILY_NOT_FOUND',message:'La clasificación de Inventario no existe',
});
return rows[0];
}
private async validateParent(
manager:EntityManager,
level:'INSTALLATION'|'SUBINSTALLATION',
parentFamilyId:string|null,
ownId:string|null,
):Promise<string|null> {
if (level==='INSTALLATION') {
if (parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_NOT_ALLOWED',
message:'Una clasificación de Instalación no tiene clasificación padre',
});
return null;
}
if (!parentFamilyId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_REQUIRED',
message:'Una Subinstalación debe pertenecer a un tipo de Instalación',
});
if (parentFamilyId===ownId) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_CYCLE',message:'Una clasificación no puede ser su propio padre',
});
const rows=(await manager.query(`
SELECT id FROM inventory_families
WHERE id=$1::uuid AND level='INSTALLATION' AND is_active=true
LIMIT 1
`,[parentFamilyId])) as Array<{id:string}>;
if (!rows[0]) throw new BadRequestException({
code:'INVENTORY_FAMILY_PARENT_INVALID',
message:'La Subinstalación debe vincularse a una clasificación de Instalación activa',
});
return parentFamilyId;
}
private cleanLabels(labels:string[]):string[] {
const unique=new Map<string,string>();
for (const raw of labels) {
const clean=raw.trim();
if (!clean) continue;
const identity=clean.toLocaleLowerCase('es-AR');
if (!unique.has(identity)) unique.set(identity,clean);
}
if (unique.size>100) throw new ConflictException({
code:'INVENTORY_FAMILY_TOO_MANY_FIELDS',message:'La clasificación admite hasta 100 campos de información',
});
return [...unique.values()];
}
}
@@ -39,6 +39,22 @@ type MergeRow = {
requestId: string | null;
};
type DocumentInvariantRow = {
actId: string;
actStatus: string;
lockedSha256: string | null;
closureSha256: string | null;
sealedAt: Date | null;
actVersion: number;
reportId: string | null;
reportStatus: string | null;
reportActClosureSha256: string | null;
reportFrozenSha256: string | null;
gedoPdfSha256: string | null;
wordSha256: string | null;
reportRevision: number | null;
};
const MERGEABLE_TYPES = new Set(['instalacion', 'subinstalacion']);
@Injectable()
@@ -161,6 +177,19 @@ export class InventoryMergeService {
if (!source || !canonical) throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
this.validatePair(source, canonical);
// Empresa is temporal inspection context, not physical ownership. A merge is
// valid when both records resolve to the same Area in the physical hierarchy,
// even if their historical operator snapshots differ.
const [sourceAreaId, canonicalAreaId] = await Promise.all([
this.resolvePhysicalAreaId(manager, source.id),
this.resolvePhysicalAreaId(manager, canonical.id),
]);
if (!sourceAreaId || sourceAreaId !== canonicalAreaId) throw new BadRequestException({
code: 'INVENTORY_MERGE_AREA_MISMATCH',
message: 'Los duplicados deben pertenecer a la misma Área física',
});
const sourceParentCanonical = source.parentId
? await this.resolveCanonicalId(manager, source.parentId)
: null;
@@ -197,6 +226,8 @@ export class InventoryMergeService {
});
}
const affectedAssetIds = [source.id, canonical.id];
const documentInvariantsBefore = await this.documentInvariants(manager, affectedAssetIds);
const [sourceSnapshot, canonicalSnapshot] = await Promise.all([
this.snapshot(manager, source.id),
this.snapshot(manager, canonical.id),
@@ -279,6 +310,17 @@ export class InventoryMergeService {
request,
);
// No historical Acta/Finding/Informe foreign key is rewritten by a merge.
// Verify that legal/documentary fingerprints are byte-for-byte unchanged
// before committing the transaction; otherwise rollback the entire merge.
const documentInvariantsAfter = await this.documentInvariants(manager, affectedAssetIds);
if (JSON.stringify(documentInvariantsBefore) !== JSON.stringify(documentInvariantsAfter)) {
throw new ConflictException({
code: 'INVENTORY_MERGE_DOCUMENT_INVARIANT_BROKEN',
message: 'La fusión intentó alterar la huella documental histórica y fue revertida',
});
}
const result = {
merge: mergeRecord,
source: { id: source.id, code: source.code, name: source.name },
@@ -286,6 +328,7 @@ export class InventoryMergeService {
sourceVersionNumber,
reparentedChildIds,
historyPolicy: 'HISTORICAL_REFERENCES_PRESERVED',
documentaryInvariantsVerified: true,
};
await this.audit.record({
...administrationAuditContext(principal, request),
@@ -298,10 +341,12 @@ export class InventoryMergeService {
operation: 'CHRONOLOGICAL_MERGE',
sourceAssetId: source.id,
canonicalAssetId: canonical.id,
physicalAreaId: sourceAreaId,
reason: dto.reason,
sourceVersionNumber,
reparentedChildIds,
historicalReferencesRewritten: false,
documentaryInvariantsVerified: true,
},
}, manager);
return result;
@@ -322,14 +367,59 @@ export class InventoryMergeService {
code: 'INVENTORY_MERGE_CANONICAL_INACTIVE',
message: 'El registro canónico no puede estar inactivo',
});
if (!source.operationalAreaId || !source.operatorCompanyId
|| source.operationalAreaId !== canonical.operationalAreaId
|| source.operatorCompanyId !== canonical.operatorCompanyId) {
throw new BadRequestException({
code: 'INVENTORY_MERGE_CONTEXT_MISMATCH',
message: 'Los registros deben pertenecer a la misma Área y Operadora',
});
}
}
private async resolvePhysicalAreaId(manager: EntityManager, assetId: string): Promise<string | null> {
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id,0 AS depth
FROM assets asset WHERE asset.id=$1::uuid
UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id,lineage.depth+1
FROM assets parent
JOIN lineage ON lineage.parent_id=parent.id
WHERE lineage.depth<32
)
SELECT lineage.id
FROM lineage
JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
ORDER BY lineage.depth
LIMIT 1
`, [assetId])) as Array<{ id: string }>;
return rows[0]?.id ?? null;
}
private async documentInvariants(manager: EntityManager, assetIds: string[]): Promise<DocumentInvariantRow[]> {
return (await manager.query(`
WITH affected_acts AS (
SELECT DISTINCT act.id
FROM inspection_acts act
LEFT JOIN inspection_act_assets act_asset
ON act_asset.act_id=act.id AND act_asset.included=true
LEFT JOIN inspection_findings finding ON finding.act_id=act.id
WHERE act_asset.asset_id=ANY($1::uuid[])
OR finding.asset_id=ANY($1::uuid[])
)
SELECT
act.id AS "actId",
act.status AS "actStatus",
act.locked_sha256 AS "lockedSha256",
act.closure_sha256 AS "closureSha256",
act.sealed_at AS "sealedAt",
act.current_version AS "actVersion",
report.id AS "reportId",
report.status AS "reportStatus",
report.act_closure_sha256 AS "reportActClosureSha256",
report.frozen_sha256 AS "reportFrozenSha256",
report.gedo_pdf_sha256 AS "gedoPdfSha256",
report.word_sha256 AS "wordSha256",
report.current_revision_number AS "reportRevision"
FROM affected_acts affected
JOIN inspection_acts act ON act.id=affected.id
LEFT JOIN inspection_reports report ON report.act_id=act.id
ORDER BY act.id,report.id NULLS FIRST
`, [assetIds])) as DocumentInvariantRow[];
}
private async loadAsset(manager: EntityManager, id: string, lock: boolean): Promise<MergeableAssetRow> {
@@ -36,18 +36,17 @@ type ParentRow = {
code: string;
name: string;
typeCode: string;
operationalAreaId: string | null;
operatorCompanyId: string | null;
inventoryFamilyId: string | null;
};
const TYPE_CODE_BY_KIND: Record<InventoryStructureKind, string> = {
const TYPE_CODE_BY_KIND: Record<Exclude<InventoryStructureKind, 'EMPRESA'>, string> = {
AREA: 'area',
YACIMIENTO: 'yacimiento',
INSTALACION: 'instalacion',
SUBINSTALACION: 'subinstalacion',
};
const PARENT_TYPE_BY_KIND: Record<InventoryStructureKind, string | null> = {
EMPRESA: null,
AREA: null,
YACIMIENTO: 'area',
INSTALACION: 'yacimiento',
@@ -70,18 +69,29 @@ export class InventoryStructureService {
const types = (await this.dataSource.query(`
SELECT id,code,name
FROM asset_types
WHERE lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
AND is_active=true
ORDER BY CASE lower(code)
WHEN 'area' THEN 1 WHEN 'yacimiento' THEN 2
WHEN 'instalacion' THEN 3 WHEN 'subinstalacion' THEN 4 ELSE 9 END
WHERE (
lower(code) IN ('area','yacimiento','instalacion','subinstalacion')
OR operational_role='COMPANY'
) AND is_active=true
ORDER BY CASE
WHEN operational_role='COMPANY' THEN 0
WHEN lower(code)='area' THEN 1
WHEN lower(code)='yacimiento' THEN 2
WHEN lower(code)='instalacion' THEN 3
WHEN lower(code)='subinstalacion' THEN 4 ELSE 9 END
`)) as StructureTypeRow[];
if (types.length !== 4) {
const company = types.find((item) => ['empresa','organizacion'].includes(item.code.toLowerCase()));
const area = types.find((item) => item.code.toLowerCase()==='area');
const yacimiento = types.find((item) => item.code.toLowerCase()==='yacimiento');
const instalacion = types.find((item) => item.code.toLowerCase()==='instalacion');
const subinstalacion = types.find((item) => item.code.toLowerCase()==='subinstalacion');
if (!company || !area || !yacimiento || !instalacion || !subinstalacion) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPES_INCOMPLETE',
message: 'La estructura del Inventario todavía no está completamente configurada',
message: 'La configuración maestra de Empresa e Inventario todavía no está completa',
});
}
const families = (await this.dataSource.query(`
SELECT family.id,family.code,family.name,family.level,
family.legacy_type_code AS "legacyTypeCode",
@@ -93,12 +103,16 @@ export class InventoryStructureService {
WHERE family.is_active=true
ORDER BY family.level,family.name,family.code
`)) as FamilyRow[];
return {
independentMasters: [
{ kind: 'EMPRESA', label: 'Empresa', type: company, parentKind: null, requiresFamily: false },
],
levels: [
{ kind: 'AREA', label: 'Área', type: types.find((item) => item.code.toLowerCase()==='area'), parentKind: null, requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: types.find((item) => item.code.toLowerCase()==='yacimiento'), parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: types.find((item) => item.code.toLowerCase()==='instalacion'), parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: types.find((item) => item.code.toLowerCase()==='subinstalacion'), parentKind: 'INSTALACION', requiresFamily: true },
{ kind: 'AREA', label: 'Área', type: area, parentKind: null, requiresFamily: false },
{ kind: 'YACIMIENTO', label: 'Yacimiento', type: yacimiento, parentKind: 'AREA', requiresFamily: false },
{ kind: 'INSTALACION', label: 'Instalación', type: instalacion, parentKind: 'YACIMIENTO', requiresFamily: true },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', type: subinstalacion, parentKind: 'INSTALACION', requiresFamily: true },
],
installationFamilies: families.filter((item) => item.level==='INSTALLATION'),
subinstallationFamilies: families.filter((item) => item.level==='SUBINSTALLATION'),
@@ -107,7 +121,7 @@ export class InventoryStructureService {
async parents(kindValue: string, search?: string) {
const kind = kindValue.toUpperCase() as InventoryStructureKind;
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA') {
if (!(kind in PARENT_TYPE_BY_KIND) || kind === 'AREA' || kind === 'EMPRESA') {
throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_PARENT_KIND_INVALID',
message: 'El nivel indicado no requiere un registro padre',
@@ -154,9 +168,8 @@ export class InventoryStructureService {
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);
const code = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent?.operationalAreaId ?? null;
const operatorCompanyId = parent?.operatorCompanyId ?? null;
const generatedCode = dto.code?.trim().toUpperCase() || this.generatedCode(dto.kind, dto.name);
const operationalAreaId = parent ? await this.resolveAreaId(manager, parent) : null;
const inserted = (await manager.query(`
INSERT INTO assets (
@@ -164,30 +177,37 @@ export class InventoryStructureService {
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,$5::uuid,
$6::varchar,$7::varchar,$8::varchar,$9::text,$10::asset_information_status,$11::asset_operational_status,
$12::varchar,$13::varchar,$14::varchar,$15::text,$16::uuid,$16::uuid,$16::uuid
$1::uuid,$2::uuid,$3::uuid,NULL,$4::uuid,
$5::varchar,$6::varchar,$7::varchar,$8::text,$9::asset_information_status,$10::asset_operational_status,
$11::varchar,$12::varchar,$13::varchar,$14::text,$15::uuid,$15::uuid,$15::uuid
) RETURNING id
`, [
type.id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
family?.id ?? null,
code,
generatedCode,
dto.name,
dto.commonName ?? null,
dto.description ?? null,
AssetInformationStatus.DRAFT,
AssetOperationalStatus.UNKNOWN,
AssetDataOrigin.MANUAL,
'Inventario estructural F3.1',
`inventory-structure:${dto.kind.toLowerCase()}`,
dto.kind === 'EMPRESA' ? 'Maestro de Empresas F5' : 'Estructura de Inventario F5',
dto.kind === 'EMPRESA' ? 'inventory-master:empresa' : `inventory-structure:${dto.kind.toLowerCase()}`,
family ? `Familia técnica: ${family.code} · ${family.name}` : null,
principal.userId,
])) as Array<{ id: string }>;
const id = inserted[0]?.id;
if (!id) throw new Error('No se pudo crear el registro estructural');
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,
@@ -200,13 +220,12 @@ export class InventoryStructureService {
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)
) VALUES ($1,$2,$3,NULL,CURRENT_TIMESTAMP,$4,$5,'WEB',$6,$7)
`, [
id,
parent?.id ?? null,
operationalAreaId,
operatorCompanyId,
'Alta guiada de Inventario estructural F3.1',
dto.kind === 'EMPRESA' ? 'Alta guiada de Empresa independiente F5' : 'Alta guiada de estructura de Inventario F5',
versionNumber,
request.requestId,
principal.userId,
@@ -223,6 +242,7 @@ export class InventoryStructureService {
inventoryStructureKind: dto.kind,
inventoryFamilyId: family?.id ?? null,
inventoryFamilyCode: family?.code ?? null,
operatorOwnership: false,
},
}, manager);
return created;
@@ -231,7 +251,7 @@ export class InventoryStructureService {
if (isUniqueViolation(error)) {
throw new ConflictException({
code: 'ASSET_CODE_ALREADY_EXISTS',
message: 'Ya existe un registro de Inventario con ese código',
message: 'Ya existe un registro con ese código',
});
}
throw error;
@@ -239,10 +259,11 @@ export class InventoryStructureService {
}
private async requireStructureType(manager: EntityManager, kind: InventoryStructureKind): Promise<StructureTypeRow> {
const rows = (await manager.query(`
SELECT id,code,name FROM asset_types
WHERE lower(code)=lower($1::text) AND is_active=true LIMIT 1
`, [TYPE_CODE_BY_KIND[kind]])) as StructureTypeRow[];
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<InventoryStructureKind,'EMPRESA'>]];
const rows = (await manager.query(sql,params)) as StructureTypeRow[];
if (!rows[0]) {
throw new ConflictException({
code: 'INVENTORY_STRUCTURE_TYPE_NOT_CONFIGURED',
@@ -261,8 +282,10 @@ export class InventoryStructureService {
if (!expectedType) {
if (parentId) {
throw new BadRequestException({
code: 'INVENTORY_AREA_MUST_BE_ROOT',
message: 'Un Área se crea como registro raíz y no puede tener padre',
code: 'INVENTORY_ROOT_MUST_NOT_HAVE_PARENT',
message: kind === 'EMPRESA'
? 'Una Empresa es un maestro independiente y no puede tener padre'
: 'Un Área es un registro raíz y no puede tener padre',
});
}
return null;
@@ -275,8 +298,6 @@ export class InventoryStructureService {
}
const rows = (await manager.query(`
SELECT asset.id,asset.code,asset.name,type.code AS "typeCode",
asset.operational_area_id AS "operationalAreaId",
asset.operator_company_id AS "operatorCompanyId",
asset.inventory_family_id AS "inventoryFamilyId"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
@@ -294,6 +315,30 @@ export class InventoryStructureService {
return parent;
}
private async resolveAreaId(manager: EntityManager,parent: ParentRow):Promise<string> {
if (parent.typeCode.toLowerCase()==='area') return parent.id;
const rows = (await manager.query(`
WITH RECURSIVE lineage AS (
SELECT asset.id,asset.parent_id,asset.asset_type_id FROM assets asset WHERE asset.id=$1::uuid
UNION ALL
SELECT parent.id,parent.parent_id,parent.asset_type_id
FROM assets parent JOIN lineage child ON child.parent_id=parent.id
)
SELECT lineage.id
FROM lineage JOIN asset_types type ON type.id=lineage.asset_type_id
WHERE type.operational_role='AREA'
LIMIT 1
`,[parent.id])) as IdRow[];
const areaId=rows[0]?.id;
if (!areaId) {
throw new ConflictException({
code:'INVENTORY_STRUCTURE_AREA_ANCESTOR_MISSING',
message:'La ubicación seleccionada no pertenece a un Área válida',
});
}
return areaId;
}
private async requireFamily(
manager: EntityManager,
kind: InventoryStructureKind,
@@ -304,7 +349,7 @@ export class InventoryStructureService {
if (!expectedLevel) {
if (familyId) throw new BadRequestException({
code: 'INVENTORY_STRUCTURE_FAMILY_NOT_ALLOWED',
message: 'Área y Yacimiento no llevan familia técnica',
message: 'Empresa, Área y Yacimiento no llevan familia técnica',
});
return null;
}
@@ -339,7 +384,7 @@ export class InventoryStructureService {
}
private generatedCode(kind: InventoryStructureKind, name: string): string {
const prefix = kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
const prefix = kind === 'EMPRESA' ? 'EMP' : kind === 'YACIMIENTO' ? 'YAC' : kind === 'INSTALACION' ? 'INST' : kind === 'SUBINSTALACION' ? 'SUB' : 'AREA';
const readable = name
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
@@ -356,6 +401,7 @@ export class InventoryStructureService {
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 family.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT(
'id',family.id,'code',family.code,'name',family.name,'level',family.level,
'informationLabels',family.information_labels
@@ -364,9 +410,12 @@ export class InventoryStructureService {
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 inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [id]);
return rows[0];
}
}
type IdRow = { id: string };
+46 -10
View File
@@ -1,7 +1,7 @@
import { Injectable } from '@nestjs/common';
import { DataSource } from 'typeorm';
export interface DashboardAuditItem {
export interface DashboardInspectorActivityItem {
id: string;
occurredAt: Date;
actorUsername: string | null;
@@ -19,6 +19,7 @@ interface DashboardCountsRow {
inactiveUsers: number | string;
activeSessions: number | string;
openFindings: number | string;
actsInFollowUp: number | string;
findingsWithoutControlDate: number | string;
overdueControls: number | string;
controlsNext30Days: number | string;
@@ -49,9 +50,14 @@ export class DashboardService {
return this.dataSource.transaction('REPEATABLE READ', async (manager) => {
const [counts] = (await manager.query(`
SELECT
(SELECT COUNT(*) FROM assets WHERE information_status <> 'INACTIVE') AS "totalAssets",
(SELECT COUNT(*) FROM assets WHERE information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
(SELECT COUNT(*) FROM assets asset WHERE asset.information_status <> 'INACTIVE' AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)) AS "assetsWithoutGeometry",
(SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status <> 'INACTIVE') AS "totalAssets",
(SELECT COUNT(*) FROM assets WHERE is_inventory_instance=true AND information_status NOT IN ('VALIDATED','INACTIVE')) AS "assetsNeedValidation",
(
SELECT COUNT(*) FROM assets asset
WHERE asset.is_inventory_instance=true
AND asset.information_status <> 'INACTIVE'
AND NOT EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id=asset.id)
) AS "assetsWithoutGeometry",
(SELECT COUNT(*) FROM inspection_visits WHERE status='PLANNED') AS "plannedInspections",
(SELECT COUNT(*) FROM users WHERE status = 'ACTIVE') AS "activeUsers",
(SELECT COUNT(*) FROM users WHERE status = 'INACTIVE') AS "inactiveUsers",
@@ -61,9 +67,22 @@ export class DashboardService {
WHERE revoked_at IS NULL
AND expires_at > CURRENT_TIMESTAMP
) AS "activeSessions",
(SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN') AS "openFindings",
(
SELECT COUNT(*) FROM inspection_findings WHERE status = 'OPEN'
) AS "openFindings",
SELECT COUNT(*)
FROM inspection_acts act
WHERE act.status IN ('SEALED','CLOSED','RECTIFIED')
AND NOT (
EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status<>'VOIDED'
)
AND NOT EXISTS (
SELECT 1 FROM inspection_findings finding
WHERE finding.act_id=act.id AND finding.status='OPEN'
)
)
) AS "actsInFollowUp",
(
SELECT COUNT(*)
FROM inspection_findings finding
@@ -115,7 +134,7 @@ export class DashboardService {
) AS "sealedActsWithoutReport"
`)) as DashboardCountsRow[];
const recentAudit = (await manager.query(`
const recentInspectorActivity = (await manager.query(`
SELECT
event.id,
event.occurred_at AS "occurredAt",
@@ -124,9 +143,25 @@ export class DashboardService {
event.entity_type AS "entityType",
event.entity_id AS "entityId"
FROM audit_events event
WHERE event.actor_user_id IS NOT NULL
AND (
EXISTS (
SELECT 1 FROM inspection_visit_members member
WHERE member.user_id=event.actor_user_id AND member.included=true
)
OR EXISTS (
SELECT 1 FROM inspection_visits visit
WHERE visit.lead_inspector_user_id=event.actor_user_id
)
)
AND (
event.action LIKE 'INSPECTION_%'
OR event.action LIKE 'ASSET_%'
OR event.action LIKE 'FINDING_%'
)
ORDER BY event.occurred_at DESC, event.id DESC
LIMIT 6
`)) as DashboardAuditItem[];
LIMIT 8
`)) as DashboardInspectorActivityItem[];
const upcomingControls = (await manager.query(`
SELECT
@@ -168,6 +203,7 @@ export class DashboardService {
inactiveUsers: Number(counts?.inactiveUsers ?? 0),
activeSessions: Number(counts?.activeSessions ?? 0),
openFindings: Number(counts?.openFindings ?? 0),
actsInFollowUp: Number(counts?.actsInFollowUp ?? 0),
findingsWithoutControlDate: Number(counts?.findingsWithoutControlDate ?? 0),
overdueControls: Number(counts?.overdueControls ?? 0),
controlsNext30Days: Number(counts?.controlsNext30Days ?? 0),
@@ -175,7 +211,7 @@ export class DashboardService {
reportsOfficialized: Number(counts?.reportsOfficialized ?? 0),
sealedActsWithoutReport: Number(counts?.sealedActsWithoutReport ?? 0),
},
recentAudit,
recentInspectorActivity,
upcomingControls,
generatedAt: new Date().toISOString(),
};
@@ -57,6 +57,9 @@ export class Asset extends TimestampedEntity {
@Column({ name: 'inventory_family_id', type: 'uuid', nullable: true })
inventoryFamilyId!: string | null;
@Column({ name: 'is_inventory_instance', type: 'boolean', default: false })
isInventoryInstance!: boolean;
@Column({ type: 'varchar', length: 120 })
code!: string;
@@ -0,0 +1,52 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class F5InventoryPhysicalInstance1790087100000 implements MigrationInterface {
name = 'F5InventoryPhysicalInstance1790087100000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
ALTER TABLE assets
ADD COLUMN is_inventory_instance boolean NOT NULL DEFAULT false
`);
await queryRunner.query(`
CREATE INDEX idx_assets_inventory_instance_active
ON assets (is_inventory_instance, information_status)
WHERE is_inventory_instance = true
`);
await queryRunner.query(`
COMMENT ON COLUMN assets.is_inventory_instance IS
'True only for a concrete Instalacion/Subinstalacion instance. Empresa, Area and Yacimiento are structural/context masters.'
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION classify_new_asset_inventory_instance()
RETURNS trigger
LANGUAGE plpgsql
AS $$
DECLARE
type_code varchar;
BEGIN
SELECT lower(code)
INTO type_code
FROM asset_types
WHERE id = NEW.asset_type_id;
NEW.is_inventory_instance := COALESCE(type_code, '') IN ('instalacion', 'subinstalacion');
RETURN NEW;
END;
$$
`);
await queryRunner.query(`
CREATE TRIGGER trg_assets_classify_inventory_instance
BEFORE INSERT OR UPDATE OF asset_type_id ON assets
FOR EACH ROW EXECUTE FUNCTION classify_new_asset_inventory_instance()
`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_assets_classify_inventory_instance ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS classify_new_asset_inventory_instance()');
await queryRunner.query('DROP INDEX IF EXISTS idx_assets_inventory_instance_active');
await queryRunner.query('ALTER TABLE assets DROP COLUMN IF EXISTS is_inventory_instance');
}
}
@@ -0,0 +1,192 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
type TypeRow = {
id: string;
code: string;
role: string;
active: boolean;
canBeRoot: boolean;
};
type RuleRow = {
childTypeId: string;
parentTypeId: string;
};
type CountRow = { total: number };
const CREATED_TYPES_TABLE = 'f5_canonical_hierarchy_created_types';
const CREATED_RULES_TABLE = 'f5_canonical_hierarchy_created_rules';
const CANONICAL_TYPES = [
{
code: 'yacimiento',
name: 'Yacimiento',
description: 'Yacimiento perteneciente a un Área.',
},
{
code: 'instalacion',
name: 'Instalación',
description: 'Instancia física de una Instalación dentro de un Yacimiento.',
},
{
code: 'subinstalacion',
name: 'Subinstalación',
description: 'Instancia física subordinada a una Instalación.',
},
] as const;
const CANONICAL_RULES = [
['yacimiento', 'area'],
['instalacion', 'yacimiento'],
['subinstalacion', 'instalacion'],
] as const;
export class F5CanonicalInventoryHierarchy1790087150000 implements MigrationInterface {
name = 'F5CanonicalInventoryHierarchy1790087150000';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE ${CREATED_TYPES_TABLE} (
type_id uuid PRIMARY KEY,
code varchar(80) NOT NULL UNIQUE,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_f5_canonical_created_type FOREIGN KEY (type_id)
REFERENCES asset_types(id) ON DELETE CASCADE
)
`);
await queryRunner.query(`
CREATE TABLE ${CREATED_RULES_TABLE} (
child_type_id uuid NOT NULL,
parent_type_id uuid NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (child_type_id,parent_type_id),
CONSTRAINT fk_f5_canonical_created_rule_child FOREIGN KEY (child_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE,
CONSTRAINT fk_f5_canonical_created_rule_parent FOREIGN KEY (parent_type_id)
REFERENCES asset_types(id) ON DELETE CASCADE
)
`);
const area = await this.requireType(queryRunner, 'area');
if (area.role !== 'AREA' || !area.active || !area.canBeRoot) {
throw new Error('F5 requires canonical active root type area with AREA operational role');
}
for (const definition of CANONICAL_TYPES) {
const inserted = (await queryRunner.query(`
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
SELECT $1::varchar,$2::varchar,$3::text,false,true,'GENERIC'
WHERE NOT EXISTS (
SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar)
)
RETURNING id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
`, [definition.code, definition.name, definition.description])) as TypeRow[];
if (inserted[0]?.id) {
await queryRunner.query(`
INSERT INTO ${CREATED_TYPES_TABLE}(type_id,code)
VALUES ($1::uuid,$2::varchar)
`, [inserted[0].id, definition.code]);
}
const type = await this.requireType(queryRunner, definition.code);
if (type.role !== 'GENERIC' || !type.active || type.canBeRoot) {
throw new Error(`F5 incompatible canonical type configuration: ${definition.code}`);
}
}
for (const [childCode, parentCode] of CANONICAL_RULES) {
const inserted = (await queryRunner.query(`
INSERT INTO asset_type_parent_rules(child_type_id,parent_type_id)
SELECT child.id,parent.id
FROM asset_types child CROSS JOIN asset_types parent
WHERE lower(child.code)=lower($1::varchar)
AND lower(parent.code)=lower($2::varchar)
AND NOT EXISTS (
SELECT 1 FROM asset_type_parent_rules existing
WHERE existing.child_type_id=child.id AND existing.parent_type_id=parent.id
)
RETURNING child_type_id AS "childTypeId",parent_type_id AS "parentTypeId"
`, [childCode, parentCode])) as RuleRow[];
if (inserted[0]?.childTypeId && inserted[0]?.parentTypeId) {
await queryRunner.query(`
INSERT INTO ${CREATED_RULES_TABLE}(child_type_id,parent_type_id)
VALUES ($1::uuid,$2::uuid)
`, [inserted[0].childTypeId, inserted[0].parentTypeId]);
}
}
const [verified] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE (lower(child.code)='yacimiento' AND lower(parent.code)='area')
OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento')
OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion')
`)) as CountRow[];
if (Number(verified?.total ?? 0) !== 3) {
throw new Error(`F5 canonical hierarchy verification failed: rules=${Number(verified?.total ?? 0)}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const [usedCreatedTypes] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN ${CREATED_TYPES_TABLE} owned ON owned.type_id=asset.asset_type_id
`)) as CountRow[];
if (Number(usedCreatedTypes?.total ?? 0) !== 0) {
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type is already used by Inventory');
}
const [foreignRules] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM asset_type_parent_rules rule
WHERE (
rule.child_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
OR rule.parent_type_id IN (SELECT type_id FROM ${CREATED_TYPES_TABLE})
)
AND NOT EXISTS (
SELECT 1 FROM ${CREATED_RULES_TABLE} owned
WHERE owned.child_type_id=rule.child_type_id
AND owned.parent_type_id=rule.parent_type_id
)
`)) as CountRow[];
if (Number(foreignRules?.total ?? 0) !== 0) {
throw new Error('Cannot safely rollback F5 canonical hierarchy: an F5-created type gained external parent rules');
}
await queryRunner.query(`
DELETE FROM asset_type_parent_rules rule
USING ${CREATED_RULES_TABLE} owned
WHERE rule.child_type_id=owned.child_type_id
AND rule.parent_type_id=owned.parent_type_id
`);
await queryRunner.query(`
DELETE FROM asset_types type
USING ${CREATED_TYPES_TABLE} owned
WHERE type.id=owned.type_id
`);
await queryRunner.query(`DROP TABLE ${CREATED_RULES_TABLE}`);
await queryRunner.query(`DROP TABLE ${CREATED_TYPES_TABLE}`);
}
private async requireType(queryRunner: QueryRunner, code: string): Promise<TypeRow> {
const rows = (await queryRunner.query(`
SELECT id,code,operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
FROM asset_types
WHERE lower(code)=lower($1::varchar)
ORDER BY created_at
`, [code])) as TypeRow[];
if (rows.length !== 1) {
throw new Error(`F5 requires exactly one canonical asset type ${code}; found ${rows.length}`);
}
return rows[0];
}
}
@@ -0,0 +1,602 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import { loadF5InventoryAuthoritativeSource } from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string };
type CountRow = { total: number };
const TERRITORY_DOCUMENT_NUMBER = 'DH-F5-TERRITORY';
const TERRITORY_SOURCE_NAME = 'Tablas de yacimiento y areas.xlsx';
const BACKUP_TABLE = 'f5_territory_relation_backups';
function key(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function code(prefix: string, value: string, length = 12): string {
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
}
function uniqueBy<T>(values: T[], identity: (value: T) => string): T[] {
const seen = new Set<string>();
const output: T[] = [];
for (const value of values) {
const id = identity(value);
if (seen.has(id)) continue;
seen.add(id);
output.push(value);
}
return output;
}
export class F5AuthoritativeTerritory1790087200000 implements MigrationInterface {
name = 'F5AuthoritativeTerritory1790087200000';
public async up(queryRunner: QueryRunner): Promise<void> {
const source = loadF5InventoryAuthoritativeSource();
if (
source.areaSource.file !== TERRITORY_SOURCE_NAME
|| source.areaSource.sheet !== 'cr26e_tabla1'
|| source.areaSource.sha256 !== '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78'
|| source.areaSource.rows.length !== 230
) {
throw new Error('F5 territory source contract mismatch');
}
const rows = source.areaSource.rows;
const areaRows = uniqueBy(rows, (row) => key(row.area));
const pairRows = uniqueBy(rows, (row) => `${key(row.area)}|${key(row.yacimiento)}`);
if (areaRows.length !== 64 || pairRows.length !== 230) {
throw new Error(`F5 territory cardinality mismatch: areas=${areaRows.length}, pairs=${pairRows.length}`);
}
// Every Area must have one unambiguous source context. The workbook is the
// only authority for this preload; conflicting rows must abort the migration.
for (const areaRow of areaRows) {
const sameArea = rows.filter((row) => key(row.area)===key(areaRow.area));
const dimensions = [
new Set(sameArea.map((row) => key(row.departamento))),
new Set(sameArea.map((row) => key(row.tipoConcesion))),
new Set(sameArea.map((row) => key(row.empresaOperadora))),
];
if (dimensions.some((values) => values.size !== 1)) {
throw new Error(`F5 territory source has conflicting Area context: ${areaRow.area}`);
}
}
await this.assertCanonicalTypesAndRules(queryRunner);
await this.installHierarchyGuard(queryRunner);
await this.ensureBackupTable(queryRunner);
const preExistingDocument = await this.optionalId(
queryRunner,
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
[TERRITORY_DOCUMENT_NUMBER],
);
if (preExistingDocument) {
throw new Error('F5 territory source document already exists before migration');
}
const insertedDocument = (await queryRunner.query(`
INSERT INTO source_documents (
document_type, document_number, title, issuer, external_reference, notes
) VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4)
RETURNING id
`, [
TERRITORY_DOCUMENT_NUMBER,
TERRITORY_SOURCE_NAME,
`sha256:${source.areaSource.sha256}`,
`F5 · fuente territorial autorizada · hoja ${source.areaSource.sheet} · ${rows.length} filas`,
])) as IdRow[];
const sourceDocumentId = insertedDocument[0]?.id;
if (!sourceDocumentId) throw new Error('F5 could not create territory source document');
const companyTypeId = await this.id(
queryRunner,
`SELECT id FROM asset_types WHERE operational_role='COMPANY' AND is_active=true ORDER BY (lower(code)='empresa') DESC,created_at LIMIT 1`,
[],
'COMPANY asset type',
);
const areaTypeId = await this.id(
queryRunner,
`SELECT id FROM asset_types WHERE operational_role='AREA' AND is_active=true ORDER BY (lower(code)='area') DESC,created_at LIMIT 1`,
[],
'AREA asset type',
);
const fieldTypeId = await this.id(
queryRunner,
`SELECT id FROM asset_types WHERE lower(code)='yacimiento' AND is_active=true LIMIT 1`,
[],
'Yacimiento asset type',
);
const companyNames = [...new Set(rows.map((row) => row.empresaOperadora.trim()))]
.filter((name) => name && key(name) !== key('Sin Empresa Operadora'))
.sort((a, b) => a.localeCompare(b, 'es'));
const companyIds = new Map<string, string>();
for (const companyName of companyNames) {
const companyId = await this.ensureRootAsset(queryRunner, {
typeId: companyTypeId,
role: 'COMPANY',
code: code('F5-ORG', key(companyName)),
name: companyName,
sourceDocumentId,
sourceReference: `F5:TERRITORY:COMPANY:${code('SRC', key(companyName), 10)}`,
});
companyIds.set(key(companyName), companyId);
await queryRunner.query(`
INSERT INTO organization_profiles (asset_id,organization_kind,legal_name)
VALUES ($1::uuid,$2::organization_kind,$3)
ON CONFLICT (asset_id) DO NOTHING
`, [companyId, companyName.trim().toUpperCase().startsWith('UTE (') ? 'UTE' : 'COMPANY', companyName]);
}
const departmentIds = new Map<string, string>();
for (const departmentName of [...new Set(rows.map((row) => row.departamento.trim()))].sort((a,b)=>a.localeCompare(b,'es'))) {
const normalized = key(departmentName);
let departmentId = await this.optionalId(queryRunner, `
SELECT id FROM administrative_departments
WHERE province_code='MENDOZA' AND normalized_name=$1 AND is_active=true
LIMIT 1
`, [normalized]);
if (!departmentId) {
const inserted = (await queryRunner.query(`
INSERT INTO administrative_departments (
province_code,code,name,normalized_name,is_active,source_document_id
) VALUES ('MENDOZA',$1,$2,$3,true,$4::uuid)
RETURNING id
`, [code('F5-DEP', normalized, 10), departmentName, normalized, sourceDocumentId])) as IdRow[];
departmentId=inserted[0]?.id ?? null;
}
if (!departmentId) throw new Error(`F5 could not seed department ${departmentName}`);
departmentIds.set(normalized, departmentId);
}
const areaIds = new Map<string, string>();
for (const areaRow of areaRows) {
const areaId = await this.ensureRootAsset(queryRunner, {
typeId: areaTypeId,
role: 'AREA',
code: code('F5-AREA', key(areaRow.area)),
name: areaRow.area,
sourceDocumentId,
sourceReference: `F5:TERRITORY:AREA:${code('SRC', key(areaRow.area), 10)}`,
});
areaIds.set(key(areaRow.area), areaId);
const departmentId = departmentIds.get(key(areaRow.departamento));
if (!departmentId) throw new Error(`F5 missing department ${areaRow.departamento}`);
await this.backupAndCloseDepartmentRelations(queryRunner,areaId,departmentId);
await queryRunner.query(`
INSERT INTO area_department_relations (
area_id,department_id,valid_from,source_document_id,notes
)
SELECT $1::uuid,$2::uuid,CURRENT_DATE,$3::uuid,$4
WHERE NOT EXISTS (
SELECT 1 FROM area_department_relations
WHERE area_id=$1::uuid AND department_id=$2::uuid AND valid_until IS NULL
)
`, [
areaId,
departmentId,
sourceDocumentId,
`F5 · ${TERRITORY_SOURCE_NAME} · ${source.areaSource.sheet}`,
]);
const operatorId = companyIds.get(key(areaRow.empresaOperadora)) ?? null;
await this.backupAndCloseOperatorRelations(queryRunner,areaId,operatorId);
if (operatorId) {
await queryRunner.query(`
INSERT INTO area_company_relations (
area_id,company_id,relation_role,source_document_id,valid_from,start_reason
)
SELECT $1::uuid,$2::uuid,'OPERATOR',$3::uuid,CURRENT_TIMESTAMP,$4
WHERE NOT EXISTS (
SELECT 1 FROM area_company_relations
WHERE area_id=$1::uuid AND company_id=$2::uuid
AND relation_role='OPERATOR' AND valid_until IS NULL
)
`, [
areaId,
operatorId,
sourceDocumentId,
`F5 · operadora vigente según ${TERRITORY_SOURCE_NAME}`,
]);
}
const rightType = areaRow.tipoConcesion === 'Exploración'
? 'EXPLORATION_PERMIT'
: areaRow.tipoConcesion === 'Explotación'
? 'EXPLOITATION_CONCESSION'
: 'OTHER';
const rightName = `${areaRow.tipoConcesion} · ${areaRow.area}`;
await queryRunner.query(`
INSERT INTO area_legal_rights (
area_id,right_type,name,status,source_document_id,notes
)
SELECT $1::uuid,$2::area_legal_right_type,$3::varchar,'ACTIVE',$4::uuid,$5
WHERE NOT EXISTS (
SELECT 1 FROM area_legal_rights
WHERE area_id=$1::uuid
AND right_type=$2::area_legal_right_type
AND lower(btrim(name))=lower(btrim($3::varchar))
AND status IN ('ACTIVE','PENDING')
)
`, [
areaId,
rightType,
rightName,
sourceDocumentId,
`F5 · tipo de concesión tomado literalmente de ${TERRITORY_SOURCE_NAME}`,
]);
}
for (const row of pairRows) {
const areaId = areaIds.get(key(row.area));
if (!areaId) throw new Error(`F5 missing area ${row.area}`);
const sourceReference = `F5:TERRITORY:YAC:${code('SRC', `${key(row.area)}|${key(row.yacimiento)}`, 12)}`;
let yacimientoId = await this.optionalId(queryRunner, `
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE lower(type.code)='yacimiento'
AND asset.parent_id=$1::uuid
AND asset.information_status<>'INACTIVE'
AND lower(btrim(asset.name))=lower(btrim($2))
ORDER BY asset.created_at
LIMIT 1
`, [areaId, row.yacimiento]);
if (!yacimientoId) {
const inserted = (await queryRunner.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,
code,name,description,information_status,operational_status,
data_origin,source_name,source_reference,source_notes,is_inventory_instance
) VALUES (
$1::uuid,$2::uuid,NULL,NULL,$3,$4,$5,'VALIDATED','UNKNOWN',
'PROVIDED_DOCUMENT',$6,$7,$8,false
) RETURNING id
`, [
fieldTypeId,
areaId,
code('F5-YAC', `${key(row.area)}|${key(row.yacimiento)}`),
row.yacimiento,
`Yacimiento del Área ${row.area}`,
TERRITORY_SOURCE_NAME,
sourceReference,
`${source.areaSource.sheet} · fila ${row.sourceRow}`,
])) as IdRow[];
yacimientoId = inserted[0]?.id ?? null;
}
if (!yacimientoId) throw new Error(`F5 could not seed yacimiento ${row.area} / ${row.yacimiento}`);
await this.linkSource(queryRunner, yacimientoId, sourceDocumentId, `Hoja ${source.areaSource.sheet} · fila ${row.sourceRow}`);
}
const [verification] = (await queryRunner.query(`
SELECT
COUNT(DISTINCT asset.id) FILTER (WHERE type.operational_role='AREA')::integer AS areas,
COUNT(DISTINCT asset.id) FILTER (WHERE lower(type.code)='yacimiento')::integer AS yacimientos
FROM asset_source_documents link
JOIN assets asset ON asset.id=link.asset_id
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE link.document_id=$1::uuid
`, [sourceDocumentId])) as Array<{ areas: number; yacimientos: number }>;
if (Number(verification?.areas ?? 0) !== 64 || Number(verification?.yacimientos ?? 0) !== 230) {
throw new Error(`F5 territory preload verification failed: ${JSON.stringify(verification ?? {})}`);
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const sourceDocumentId = await this.optionalId(
queryRunner,
`SELECT id FROM source_documents WHERE document_number=$1 AND issuer='Dirección de Hidrocarburos' LIMIT 1`,
[TERRITORY_DOCUMENT_NUMBER],
);
if (!sourceDocumentId) {
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
return;
}
await this.assertRelationBackupsUnchanged(queryRunner);
await this.assertCreatedMastersUnused(queryRunner,sourceDocumentId);
await queryRunner.query(`DELETE FROM area_legal_rights WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
// Remove relations created by F5 first so restoring the previously-active
// relation cannot violate active-relation uniqueness constraints.
await queryRunner.query(`DELETE FROM area_company_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query(`DELETE FROM area_department_relations WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
await this.restoreRelationBackups(queryRunner);
await queryRunner.query(`DELETE FROM asset_source_documents WHERE document_id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:YAC:%'`);
await queryRunner.query(`DELETE FROM organization_profiles profile USING assets asset WHERE profile.asset_id=asset.id AND asset.source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:COMPANY:%'`);
await queryRunner.query(`DELETE FROM assets WHERE source_reference LIKE 'F5:TERRITORY:AREA:%'`);
await queryRunner.query(`DELETE FROM administrative_departments WHERE source_document_id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query(`DELETE FROM source_documents WHERE id=$1::uuid`,[sourceDocumentId]);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
await queryRunner.query('DROP FUNCTION IF EXISTS enforce_f5_canonical_asset_hierarchy()');
await queryRunner.query(`DROP TABLE IF EXISTS ${BACKUP_TABLE}`);
}
private async assertCanonicalTypesAndRules(queryRunner: QueryRunner): Promise<void> {
const [roles] = (await queryRunner.query(`
SELECT
COUNT(*) FILTER (WHERE operational_role='AREA' AND is_active=true)::integer AS areas,
COUNT(*) FILTER (WHERE operational_role='COMPANY' AND is_active=true)::integer AS companies
FROM asset_types
`)) as Array<{areas:number; companies:number}>;
if (Number(roles?.areas ?? 0)<1 || Number(roles?.companies ?? 0)<1) {
throw new Error('F5 requires active AREA and COMPANY master types');
}
for (const [typeCode,typeName,description] of [
['yacimiento','Yacimiento','Yacimiento perteneciente a un Área.'],
['instalacion','Instalación','Instancia física de una Instalación dentro de un Yacimiento.'],
['subinstalacion','Subinstalación','Instancia física subordinada a una Instalación.'],
] as const) {
await queryRunner.query(`
INSERT INTO asset_types(code,name,description,can_be_root,is_active,operational_role)
SELECT $1::varchar,$2,$3,false,true,'GENERIC'
WHERE NOT EXISTS (SELECT 1 FROM asset_types WHERE lower(code)=lower($1::varchar))
`,[typeCode,typeName,description]);
const [type] = (await queryRunner.query(`
SELECT operational_role AS role,is_active AS active,can_be_root AS "canBeRoot"
FROM asset_types WHERE lower(code)=lower($1) LIMIT 1
`,[typeCode])) as Array<{role:string;active:boolean;canBeRoot:boolean}>;
if (!type || type.role!=='GENERIC' || !type.active || type.canBeRoot) {
throw new Error(`F5 incompatible master type configuration: ${typeCode}`);
}
}
const [ruleCount] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM asset_type_parent_rules rule
JOIN asset_types child ON child.id=rule.child_type_id
JOIN asset_types parent ON parent.id=rule.parent_type_id
WHERE (lower(child.code)='yacimiento' AND lower(parent.code)='area')
OR (lower(child.code)='instalacion' AND lower(parent.code)='yacimiento')
OR (lower(child.code)='subinstalacion' AND lower(parent.code)='instalacion')
`)) as CountRow[];
if (Number(ruleCount?.total ?? 0)!==3) {
throw new Error('F5 requires canonical parent rules Area → Yacimiento → Instalación → Subinstalación');
}
}
private async installHierarchyGuard(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_f5_canonical_asset_hierarchy()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE child_code text; parent_code text;
BEGIN
SELECT lower(code) INTO child_code FROM asset_types WHERE id=NEW.asset_type_id;
IF child_code IN ('empresa','organizacion','area') THEN
IF NEW.parent_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Empresa y Área son maestros raíz independientes';
END IF;
RETURN NEW;
END IF;
IF child_code NOT IN ('yacimiento','instalacion','subinstalacion') THEN RETURN NEW; END IF;
IF NEW.parent_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Yacimiento, Instalación y Subinstalación requieren padre';
END IF;
SELECT lower(type.code) INTO parent_code
FROM assets parent JOIN asset_types type ON type.id=parent.asset_type_id
WHERE parent.id=NEW.parent_id;
IF (child_code='yacimiento' AND parent_code<>'area')
OR (child_code='instalacion' AND parent_code<>'yacimiento')
OR (child_code='subinstalacion' AND parent_code<>'instalacion') THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='Jerarquía F5 inválida: Área → Yacimiento → Instalación → Subinstalación';
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query('DROP TRIGGER IF EXISTS trg_f5_canonical_asset_hierarchy ON assets');
await queryRunner.query(`
CREATE TRIGGER trg_f5_canonical_asset_hierarchy
BEFORE INSERT OR UPDATE OF asset_type_id,parent_id ON assets
FOR EACH ROW EXECUTE FUNCTION enforce_f5_canonical_asset_hierarchy()
`);
}
private async ensureBackupTable(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE TABLE ${BACKUP_TABLE} (
relation_kind varchar(32) NOT NULL,
relation_id uuid NOT NULL,
previous_values jsonb NOT NULL,
applied_values jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (relation_kind,relation_id),
CONSTRAINT chk_f5_territory_backup_kind CHECK (relation_kind IN ('AREA_COMPANY','AREA_DEPARTMENT'))
)
`);
}
private async backupAndCloseDepartmentRelations(queryRunner: QueryRunner,areaId:string,departmentId:string):Promise<void> {
const marker='F5: reemplazada por fuente territorial autorizada';
await queryRunner.query(`
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
SELECT 'AREA_DEPARTMENT',relation.id,
jsonb_build_object('validUntil',relation.valid_until,'notes',relation.notes),
jsonb_build_object('validUntil',CURRENT_DATE,'notes',concat_ws(E'\n',relation.notes,$3::text))
FROM area_department_relations relation
WHERE relation.area_id=$1::uuid
AND relation.valid_until IS NULL
AND relation.department_id<>$2::uuid
ON CONFLICT DO NOTHING
`,[areaId,departmentId,marker]);
await queryRunner.query(`
UPDATE area_department_relations relation
SET valid_until=(backup.applied_values->>'validUntil')::date,
notes=backup.applied_values->>'notes',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_DEPARTMENT'
AND backup.relation_id=relation.id
AND relation.area_id=$1::uuid
AND relation.valid_until IS NULL
`,[areaId]);
}
private async backupAndCloseOperatorRelations(queryRunner: QueryRunner,areaId:string,operatorId:string|null):Promise<void> {
const marker='F5: reemplazada por fuente territorial autorizada';
await queryRunner.query(`
INSERT INTO ${BACKUP_TABLE}(relation_kind,relation_id,previous_values,applied_values)
SELECT 'AREA_COMPANY',relation.id,
jsonb_build_object('validUntil',relation.valid_until,'endReason',relation.end_reason),
jsonb_build_object('validUntil',CURRENT_TIMESTAMP,'endReason',$3::text)
FROM area_company_relations relation
WHERE relation.area_id=$1::uuid
AND relation.relation_role='OPERATOR'
AND relation.valid_until IS NULL
AND ($2::uuid IS NULL OR relation.company_id<>$2::uuid)
ON CONFLICT DO NOTHING
`,[areaId,operatorId,marker]);
await queryRunner.query(`
UPDATE area_company_relations relation
SET valid_until=(backup.applied_values->>'validUntil')::timestamptz,
end_reason=backup.applied_values->>'endReason',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_COMPANY'
AND backup.relation_id=relation.id
AND relation.area_id=$1::uuid
AND relation.valid_until IS NULL
`,[areaId]);
}
private async assertRelationBackupsUnchanged(queryRunner: QueryRunner):Promise<void> {
const [changed] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM ${BACKUP_TABLE} backup
LEFT JOIN area_company_relations company_relation
ON backup.relation_kind='AREA_COMPANY' AND company_relation.id=backup.relation_id
LEFT JOIN area_department_relations department_relation
ON backup.relation_kind='AREA_DEPARTMENT' AND department_relation.id=backup.relation_id
WHERE (
backup.relation_kind='AREA_COMPANY'
AND (
company_relation.id IS NULL
OR company_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::timestamptz
OR company_relation.end_reason IS DISTINCT FROM backup.applied_values->>'endReason'
)
) OR (
backup.relation_kind='AREA_DEPARTMENT'
AND (
department_relation.id IS NULL
OR department_relation.valid_until IS DISTINCT FROM (backup.applied_values->>'validUntil')::date
OR department_relation.notes IS DISTINCT FROM backup.applied_values->>'notes'
)
)
`)) as CountRow[];
if (Number(changed?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 territory: a relation closed by the preload was modified afterwards');
}
}
private async restoreRelationBackups(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
UPDATE area_company_relations relation
SET valid_until=(backup.previous_values->>'validUntil')::timestamptz,
end_reason=backup.previous_values->>'endReason',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_COMPANY' AND backup.relation_id=relation.id
`);
await queryRunner.query(`
UPDATE area_department_relations relation
SET valid_until=(backup.previous_values->>'validUntil')::date,
notes=backup.previous_values->>'notes',
updated_at=CURRENT_TIMESTAMP
FROM ${BACKUP_TABLE} backup
WHERE backup.relation_kind='AREA_DEPARTMENT' AND backup.relation_id=relation.id
`);
}
private async assertCreatedMastersUnused(queryRunner:QueryRunner,sourceDocumentId:string):Promise<void> {
const [used] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
WHERE asset.source_reference LIKE 'F5:TERRITORY:%'
AND (
EXISTS (SELECT 1 FROM assets child WHERE child.parent_id=asset.id AND child.source_reference NOT LIKE 'F5:TERRITORY:%')
OR EXISTS (SELECT 1 FROM inspection_visits visit WHERE visit.operational_area_id=asset.id OR visit.operator_company_id=asset.id)
OR EXISTS (SELECT 1 FROM inspection_visit_assets link WHERE link.asset_id=asset.id)
OR EXISTS (SELECT 1 FROM inspection_findings finding WHERE finding.asset_id=asset.id)
)
`)) as CountRow[];
if (Number(used?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 territory: F5-created master data is already used by operational records');
}
void sourceDocumentId;
}
private async ensureRootAsset(
queryRunner: QueryRunner,
input: {
typeId: string;
role: 'AREA' | 'COMPANY';
code: string;
name: string;
sourceDocumentId: string;
sourceReference: string;
},
): Promise<string> {
let assetId = await this.optionalId(queryRunner, `
SELECT asset.id
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE type.operational_role=$1::asset_type_operational_role
AND asset.information_status<>'INACTIVE'
AND lower(btrim(asset.name))=lower(btrim($2))
ORDER BY asset.created_at
LIMIT 1
`,[input.role,input.name]);
if (!assetId) {
const inserted = (await queryRunner.query(`
INSERT INTO assets (
asset_type_id,parent_id,operational_area_id,operator_company_id,
code,name,information_status,operational_status,data_origin,
source_name,source_reference,source_notes,is_inventory_instance
) VALUES ($1::uuid,NULL,NULL,NULL,$2,$3,'VALIDATED','UNKNOWN','PROVIDED_DOCUMENT',$4,$5,$6,false)
RETURNING id
`,[
input.typeId,input.code,input.name,TERRITORY_SOURCE_NAME,input.sourceReference,
'F5 · fuente territorial autorizada',
])) as IdRow[];
assetId=inserted[0]?.id ?? null;
}
if (!assetId) throw new Error(`F5 could not seed ${input.role} ${input.name}`);
await this.linkSource(queryRunner,assetId,input.sourceDocumentId,'F5 · fuente territorial autorizada');
return assetId;
}
private async linkSource(queryRunner:QueryRunner,assetId:string,documentId:string,notes:string):Promise<void> {
await queryRunner.query(`
INSERT INTO asset_source_documents(asset_id,document_id,relation_type,notes)
VALUES ($1::uuid,$2::uuid,'SOURCE',$3)
ON CONFLICT (asset_id,document_id,relation_type) DO UPDATE SET notes=EXCLUDED.notes,updated_at=CURRENT_TIMESTAMP
`,[assetId,documentId,notes]);
}
private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise<string> {
const value=await this.optionalId(queryRunner,sql,params);
if (!value) throw new Error(`F5 could not resolve ${label}`);
return value;
}
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
const result=(await queryRunner.query(sql,params)) as IdRow[];
return result[0]?.id ?? null;
}
}
@@ -0,0 +1,182 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
/**
* F5 makes the physical hierarchy Area-owned. Empresa is never a parent nor a
* required property of Yacimiento/Instalación/Subinstalación. The current and
* historical operator/concession truth lives in area_company_relations and is
* frozen separately by each Inspección/Acta.
*
* operator_company_id is retained only as a backwards-compatible creation/
* historical snapshot. Runtime ownership and search MUST NOT depend on it.
*/
export class F5OperationalContextCompatibility1790087250000 implements MigrationInterface {
name = 'F5OperationalContextCompatibility1790087250000';
public async up(queryRunner: QueryRunner): Promise<void> {
await this.installAreaOwnedGuard(queryRunner);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await this.installLegacyPairedGuard(queryRunner);
}
private async installAreaOwnedGuard(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_operational_context()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
asset_role asset_type_operational_role;
area_role asset_type_operational_role;
company_role asset_type_operational_role;
active_relation_id uuid;
BEGIN
SELECT operational_role INTO asset_role
FROM asset_types WHERE id=NEW.asset_type_id;
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
IF NEW.operational_area_id IS NOT NULL OR NEW.operator_company_id IS NOT NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='Área y Empresa no reciben contexto operativo de Inventario';
END IF;
RETURN NEW;
END IF;
IF NEW.operator_company_id IS NOT NULL AND NEW.operational_area_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='Un snapshot de Empresa requiere un Área física';
END IF;
-- Once written, an old/current company snapshot cannot be repointed to
-- simulate physical ownership. Company changes happen in the temporal
-- Area↔Empresa relation instead.
IF TG_OP='UPDATE'
AND NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='La Empresa se cambia en la relación temporal del Área, no en el Inventario';
END IF;
IF NEW.operational_area_id IS NULL THEN
RETURN NEW;
END IF;
SELECT type.operational_role INTO area_role
FROM assets area
JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.id=NEW.operational_area_id
AND area.information_status<>'INACTIVE'
AND type.is_active=true;
IF area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operational area must be an active AREA asset';
END IF;
IF NEW.parent_id IS NULL OR NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=NEW.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent
JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1
) THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operational area must be an ancestor in the physical hierarchy';
END IF;
-- A company value is allowed only as the context snapshot that was valid
-- at creation time. It is never used to decide future membership.
IF NEW.operator_company_id IS NOT NULL THEN
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='operator snapshot must reference an active COMPANY-role asset';
END IF;
IF TG_OP='INSERT' THEN
SELECT relation.id INTO active_relation_id
FROM area_company_relations relation
WHERE relation.area_id=NEW.operational_area_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
FOR KEY SHARE;
IF active_relation_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='creation operator snapshot must be active for the selected Area';
END IF;
END IF;
END IF;
RETURN NEW;
END $$;
`);
}
/** Restores the production F4-era paired Area+Empresa guard on rollback. */
private async installLegacyPairedGuard(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION enforce_asset_operational_context()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
asset_role asset_type_operational_role;
area_role asset_type_operational_role;
company_role asset_type_operational_role;
active_relation_id uuid;
BEGIN
IF NEW.operational_area_id IS NULL AND NEW.operator_company_id IS NULL THEN
RETURN NEW;
END IF;
IF NEW.operational_area_id IS NULL OR NEW.operator_company_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',
MESSAGE='operational area and organization must be assigned together';
END IF;
SELECT operational_role INTO asset_role FROM asset_types WHERE id=NEW.asset_type_id;
IF asset_role <> 'GENERIC'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='area and organization assets cannot receive an operational assignment';
END IF;
SELECT type.operational_role INTO area_role
FROM assets area JOIN asset_types type ON type.id=area.asset_type_id
WHERE area.id=NEW.operational_area_id AND area.information_status<>'INACTIVE' AND type.is_active=true;
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 area_role IS DISTINCT FROM 'AREA'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an active AREA asset';
END IF;
IF company_role IS DISTINCT FROM 'COMPANY'::asset_type_operational_role THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operator organization must be an active COMPANY-role asset';
END IF;
SELECT relation.id INTO active_relation_id
FROM area_company_relations relation
WHERE relation.area_id=NEW.operational_area_id
AND relation.company_id=NEW.operator_company_id
AND relation.relation_role='OPERATOR'::area_organization_role
AND relation.valid_until IS NULL
FOR KEY SHARE;
IF active_relation_id IS NULL THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area and organization do not have an active OPERATOR relation';
END IF;
IF NEW.parent_id IS NULL OR NOT EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=NEW.parent_id
UNION ALL
SELECT parent.id,parent.parent_id FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=NEW.operational_area_id LIMIT 1
) THEN
RAISE EXCEPTION USING ERRCODE='23514',MESSAGE='operational area must be an ancestor in the physical hierarchy';
END IF;
RETURN NEW;
END $$;
`);
}
}
@@ -0,0 +1,606 @@
import { createHash } from 'node:crypto';
import { MigrationInterface, QueryRunner } from 'typeorm';
import {
loadF5InventoryAuthoritativeSource,
type F5InstallationCatalogRow,
type F5SubinstallationCatalogRow,
} from '../../reference-data/f5-authoritative-inventory-source';
type IdRow = { id: string };
type CountRow = { total: number };
const CATALOG_DOCUMENT_NUMBER = 'DH-F5-INVENTORY-CATALOG';
const CATALOG_CATEGORY_CODE = 'F5MODEL';
const CATALOG_SOURCE_NAME = 'final_modelov2.xlsx';
const F5_AUTO_REASON = 'F5 familia técnica: catálogo contextual automático';
const F5_SOURCE_FAMILY_COUNT = 123;
function findingKey(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
function hashCode(prefix: string, value: string, length = 12): string {
return `${prefix}-${createHash('sha1').update(value).digest('hex').slice(0, length).toUpperCase()}`;
}
function installationCode(name: string): string {
return hashCode('F5-I', findingKey(name));
}
function subinstallationCode(installation: string, name: string): string {
return hashCode('F5-S', `${findingKey(installation)}|${findingKey(name)}`);
}
function subOtherCode(parentCode: string): string {
return hashCode('F5-S-OTRO', parentCode);
}
export class F5AuthoritativeInventoryCatalog1790087300000 implements MigrationInterface {
name = 'F5AuthoritativeInventoryCatalog1790087300000';
public async up(queryRunner: QueryRunner): Promise<void> {
const source = loadF5InventoryAuthoritativeSource();
if (
source.catalogSource.file !== CATALOG_SOURCE_NAME
|| source.catalogSource.sheet !== 'Hoja1'
|| source.catalogSource.sha256 !== 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155'
|| source.catalogSource.installations.length !== 14
|| source.catalogSource.subinstallations.length !== 109
) {
throw new Error('F5 inventory catalog source contract mismatch');
}
if (source.catalogSource.universalFindings.length !== 3) {
throw new Error(`F5 universal finding contract mismatch: ${source.catalogSource.universalFindings.length}`);
}
const universalKeys = new Set(source.catalogSource.universalFindings.map(findingKey));
for (const required of [
'ORDEN Y LIMPIEZA',
'CARTELERIA PREVENTIVA / INFORMATIVA',
'EXTINTORES',
]) {
if (!universalKeys.has(findingKey(required))) {
throw new Error(`F5 missing authoritative universal finding: ${required}`);
}
}
await queryRunner.query(`
INSERT INTO source_documents (
document_type,document_number,title,issuer,external_reference,notes
)
VALUES ('SPREADSHEET',$1,$2,'Dirección de Hidrocarburos',$3,$4)
ON CONFLICT (document_number,issuer) WHERE document_number IS NOT NULL AND issuer IS NOT NULL
DO UPDATE SET
title=EXCLUDED.title,
external_reference=EXCLUDED.external_reference,
notes=EXCLUDED.notes,
updated_at=CURRENT_TIMESTAMP
`, [
CATALOG_DOCUMENT_NUMBER,
CATALOG_SOURCE_NAME,
`sha256:${source.catalogSource.sha256}`,
`F5 · catálogo técnico autorizado · hoja ${source.catalogSource.sheet} · 14 Instalaciones · 109 Subinstalaciones`,
]);
// Only the known historical spreadsheet catalog is superseded. Families
// created manually by DH (including source_reference NULL) remain untouched.
await queryRunner.query(`
UPDATE inventory_families
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories
SET is_active=false,updated_at=CURRENT_TIMESTAMP
WHERE lower(code) IN ('app26','app26r2')
`);
const installationIds = new Map<string,string>();
for (const installation of source.catalogSource.installations) {
const familyId = await this.upsertFamily(
queryRunner,
installationCode(installation.name),
installation.name,
'INSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${installation.sourceStartRow}-${installation.sourceEndRow}`,
);
installationIds.set(findingKey(installation.name),familyId);
}
for (const subinstallation of source.catalogSource.subinstallations) {
const parentId = installationIds.get(findingKey(subinstallation.installation));
if (!parentId) throw new Error(`F5 missing installation family ${subinstallation.installation}`);
const childId = await this.upsertFamily(
queryRunner,
subinstallationCode(subinstallation.installation,subinstallation.name),
subinstallation.name,
'SUBINSTALLATION',
`F5:${CATALOG_SOURCE_NAME}|${source.catalogSource.sheet}|rows:${subinstallation.sourceStartRow}-${subinstallation.sourceEndRow}${subinstallation.reference ? `|reference:${subinstallation.reference}` : ''}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const installationOtherId = await this.upsertFamily(
queryRunner,
'F5-I-OTRO',
'Otro / no catalogado',
'INSTALLATION',
'F5:SYSTEM:OTHER:INSTALLATION',
);
for (const [installationKey,parentId] of installationIds) {
const parent = source.catalogSource.installations.find((item) => findingKey(item.name)===installationKey);
if (!parent) continue;
const childId = await this.upsertFamily(
queryRunner,
subOtherCode(installationCode(parent.name)),
'Otro / no catalogado',
'SUBINSTALLATION',
`F5:SYSTEM:OTHER:SUBINSTALLATION:${installationCode(parent.name)}`,
);
await this.parentRule(queryRunner,childId,parentId);
}
const rootOtherChild = await this.upsertFamily(
queryRunner,
subOtherCode('F5-I-OTRO'),
'Otro / no catalogado',
'SUBINSTALLATION',
'F5:SYSTEM:OTHER:SUBINSTALLATION:F5-I-OTRO',
);
await this.parentRule(queryRunner,rootOtherChild,installationOtherId);
await queryRunner.query(`
INSERT INTO finding_categories(code,name,sort_order,is_active)
SELECT $1::varchar,'DH · Modelo de Inventarios F5',270,true
WHERE NOT EXISTS (SELECT 1 FROM finding_categories WHERE lower(code)=lower($1::varchar))
`, [CATALOG_CATEGORY_CODE]);
await queryRunner.query(`
UPDATE finding_categories
SET name='DH · Modelo de Inventarios F5',sort_order=270,is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)=lower($1::varchar)
`,[CATALOG_CATEGORY_CODE]);
const categoryId = await this.id(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
'F5 finding category',
);
const titleByKey = new Map<string,string>();
const register = (title: string): void => {
const clean = title.trim();
if (!clean || /^idem\b/i.test(clean) || findingKey(clean)==='hallazgos') return;
const itemKey = findingKey(clean);
if (!titleByKey.has(itemKey)) titleByKey.set(itemKey,clean);
};
for (const title of source.catalogSource.universalFindings) register(title);
for (const family of source.catalogSource.installations) for (const title of family.findings) register(title);
for (const family of source.catalogSource.subinstallations) for (const title of family.findings) register(title);
if (titleByKey.size !== 177) {
throw new Error(`F5 finding normalization contract mismatch: ${titleByKey.size}`);
}
const itemIdByKey = new Map<string,string>();
const orderedTitles = [...titleByKey.entries()].sort((a,b)=>a[1].localeCompare(b[1],'es'));
let sourceNumber=1;
for (const [itemKey,title] of orderedTitles) {
const itemCode = hashCode('F5-H',itemKey);
let itemId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_catalog_items WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[itemCode],
);
if (!itemId) {
const itemRows = (await queryRunner.query(`
INSERT INTO finding_catalog_items (
category_id,code,source_number,title,import_note,revision,is_active
) VALUES ($1::uuid,$2,$3,$4,$5,1,true)
RETURNING id
`,[
categoryId,itemCode,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
])) as IdRow[];
itemId=itemRows[0]?.id ?? null;
} else {
await queryRunner.query(`
UPDATE finding_catalog_items
SET category_id=$2::uuid,source_number=$3,title=$4,import_note=$5,
is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`,[
itemId,categoryId,sourceNumber,title,
`${CATALOG_SOURCE_NAME} · ${source.catalogSource.sheet} · F5 authoritative catalog`,
]);
}
if (!itemId) throw new Error(`F5 could not create finding ${title}`);
itemIdByKey.set(itemKey,itemId);
await queryRunner.query(`
INSERT INTO finding_catalog_item_versions(item_id,revision,snapshot,actor_username)
SELECT item.id,item.revision,
jsonb_build_object(
'id',item.id,'categoryId',category.id,'categoryCode',category.code,
'categoryName',category.name,'code',item.code,'sourceNumber',item.source_number,
'title',item.title,'legalBasis',item.legal_basis,'glossary',item.glossary,
'importNote',item.import_note,'revision',item.revision,'isActive',item.is_active
),'migration:F5'
FROM finding_catalog_items item
JOIN finding_categories category ON category.id=item.category_id
WHERE item.id=$1::uuid
AND NOT EXISTS (
SELECT 1 FROM finding_catalog_item_versions version
WHERE version.item_id=item.id AND version.revision=item.revision
)
`,[itemId]);
sourceNumber+=1;
}
// Add F5 mappings only. Never delete mappings created by office users or by
// historical migrations; inactive historical families simply stop being offered.
for (const family of source.catalogSource.installations) {
await this.mapFindings(
queryRunner,
installationCode(family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
for (const family of source.catalogSource.subinstallations) {
await this.mapFindings(
queryRunner,
subinstallationCode(family.installation,family.name),
family,
source.catalogSource.universalFindings,
itemIdByKey,
);
}
await this.installFamilySyncFunctions(queryRunner);
// Keep pre-existing profile administration untouched. Yacimiento needs a
// profile only to expose OTROS because the source does not provide a family.
await queryRunner.query(`
INSERT INTO finding_catalog_asset_type_profiles(asset_type_id,reason)
SELECT id,'F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
FROM asset_types WHERE lower(code)='yacimiento'
ON CONFLICT (asset_type_id) DO NOTHING
`);
const [counts] = (await queryRunner.query(`
SELECT
COUNT(*) FILTER (WHERE level='INSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS installations,
COUNT(*) FILTER (WHERE level='SUBINSTALLATION' AND source_reference LIKE 'F5:${CATALOG_SOURCE_NAME}%')::integer AS subinstallations
FROM inventory_families WHERE is_active=true
`)) as Array<{ installations:number; subinstallations:number }>;
if (Number(counts?.installations ?? 0)!==14 || Number(counts?.subinstallations ?? 0)!==109) {
throw new Error(`F5 family preload verification failed: ${JSON.stringify(counts ?? {})}`);
}
const [itemCount] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM finding_catalog_items
WHERE category_id=$1::uuid AND is_active=true
`,[categoryId])) as CountRow[];
if (Number(itemCount?.total ?? 0)!==177) {
throw new Error(`F5 finding preload verification failed: ${itemCount?.total ?? 0}`);
}
// Verify every universal finding is independently attached to every one of
// the 14 + 109 source families. This intentionally avoids optional DB text
// extensions such as unaccent.
for (const universalTitle of source.catalogSource.universalFindings) {
const universalItemId = itemIdByKey.get(findingKey(universalTitle));
if (!universalItemId) throw new Error(`F5 missing universal catalog item ${universalTitle}`);
const [mappedCount] = (await queryRunner.query(`
SELECT COUNT(DISTINCT mapping.inventory_family_id)::integer AS total
FROM finding_catalog_item_inventory_families mapping
JOIN inventory_families family ON family.id=mapping.inventory_family_id
WHERE mapping.catalog_item_id=$1::uuid
AND family.is_active=true
AND family.source_reference LIKE $2
`,[universalItemId,`F5:${CATALOG_SOURCE_NAME}%`])) as CountRow[];
if (Number(mappedCount?.total ?? 0)!==F5_SOURCE_FAMILY_COUNT) {
throw new Error(`F5 universal mapping verification failed for ${universalTitle}: ${mappedCount?.total ?? 0}`);
}
}
}
public async down(queryRunner: QueryRunner): Promise<void> {
const categoryId = await this.optionalId(
queryRunner,
`SELECT id FROM finding_categories WHERE lower(code)=lower($1::varchar) LIMIT 1`,
[CATALOG_CATEGORY_CODE],
);
if (categoryId) {
const [usedFinding] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM inspection_findings finding
JOIN finding_catalog_items item ON item.id=finding.catalog_item_id
WHERE item.category_id=$1::uuid
`,[categoryId])) as CountRow[];
if (Number(usedFinding?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inspection findings already reference F5 catalog items');
}
}
const [usedFamily] = (await queryRunner.query(`
SELECT COUNT(*)::integer AS total
FROM assets asset
JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE family.source_reference LIKE 'F5:%'
`)) as CountRow[];
if (Number(usedFamily?.total ?? 0)>0) {
throw new Error('Cannot safely rollback F5 catalog: inventory instances already reference F5 families');
}
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason=$1::text
`,[F5_AUTO_REASON]);
if (categoryId) {
await queryRunner.query(`
DELETE FROM finding_catalog_item_inventory_families mapping
USING finding_catalog_items item
WHERE item.id=mapping.catalog_item_id AND item.category_id=$1::uuid
`,[categoryId]);
await queryRunner.query(`
DELETE FROM finding_catalog_item_versions version
USING finding_catalog_items item
WHERE item.id=version.item_id AND item.category_id=$1::uuid
`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_catalog_items WHERE category_id=$1::uuid`,[categoryId]);
await queryRunner.query(`DELETE FROM finding_categories WHERE id=$1::uuid`,[categoryId]);
}
await queryRunner.query(`
DELETE FROM inventory_family_parent_rules rule
USING inventory_families child
WHERE child.id=rule.child_family_id AND child.source_reference LIKE 'F5:%'
`);
await queryRunner.query(`DELETE FROM inventory_families WHERE source_reference LIKE 'F5:%'`);
await queryRunner.query(`
UPDATE inventory_families
SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE source_reference LIKE 'APLICACION APP%'
OR source_reference LIKE 'SYSTEM:F3.1:%'
`);
await queryRunner.query(`
UPDATE finding_categories SET is_active=true,updated_at=CURRENT_TIMESTAMP
WHERE lower(code)='app26r2'
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_type_profiles profile
USING asset_types type
WHERE profile.asset_type_id=type.id
AND lower(type.code)='yacimiento'
AND profile.reason='F5: Yacimiento admite Hallazgos mediante OTROS; no posee familia precargada en final_modelov2.xlsx.'
`);
await queryRunner.query(`
DELETE FROM source_documents
WHERE document_number=$1::varchar AND issuer='Dirección de Hidrocarburos'
`,[CATALOG_DOCUMENT_NUMBER]);
await this.restoreF31FamilySyncFunctions(queryRunner);
// Rebuild only automatic historical overrides. Manual overrides have never
// been touched by this migration.
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F3.1 familia técnica:%'
`);
await queryRunner.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,
reason='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP
`);
}
private async upsertFamily(
queryRunner: QueryRunner,
familyCode: string,
name: string,
level: 'INSTALLATION'|'SUBINSTALLATION',
sourceReference: string,
): Promise<string> {
await queryRunner.query(`
INSERT INTO inventory_families(
code,name,level,legacy_type_code,information_labels,source_reference,is_active
) VALUES ($1,$2,$3,NULL,'[]'::jsonb,$4,true)
ON CONFLICT (code) DO UPDATE SET
name=EXCLUDED.name,level=EXCLUDED.level,legacy_type_code=NULL,
information_labels='[]'::jsonb,source_reference=EXCLUDED.source_reference,
is_active=true,updated_at=CURRENT_TIMESTAMP
`,[familyCode,name,level,sourceReference]);
return this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`inventory family ${familyCode}`,
);
}
private async parentRule(queryRunner: QueryRunner,childId:string,parentId:string):Promise<void> {
await queryRunner.query(`
INSERT INTO inventory_family_parent_rules(child_family_id,parent_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (child_family_id) DO UPDATE SET parent_family_id=EXCLUDED.parent_family_id
`,[childId,parentId]);
}
private async mapFindings(
queryRunner: QueryRunner,
familyCode: string,
family: F5InstallationCatalogRow|F5SubinstallationCatalogRow,
universalFindings: string[],
itemIdByKey: Map<string,string>,
): Promise<void> {
const familyId = await this.id(
queryRunner,
`SELECT id FROM inventory_families WHERE code=$1::varchar LIMIT 1`,
[familyCode],
`family ${familyCode}`,
);
const mapped = new Set<string>();
for (const rawTitle of [...family.findings,...universalFindings]) {
const itemKey=findingKey(rawTitle);
if (!itemKey || itemKey==='hallazgos' || mapped.has(itemKey)) continue;
mapped.add(itemKey);
const itemId=itemIdByKey.get(itemKey);
if (!itemId) throw new Error(`F5 missing finding item ${rawTitle}`);
await queryRunner.query(`
INSERT INTO finding_catalog_item_inventory_families(catalog_item_id,inventory_family_id)
VALUES ($1::uuid,$2::uuid)
ON CONFLICT (catalog_item_id,inventory_family_id) DO NOTHING
`,[itemId,familyId]);
}
}
private async installFamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides
WHERE asset_id=NEW.id AND reason LIKE 'F% familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT NEW.id,mapping.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F5 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F% familia técnica:%';
RETURN OLD;
END IF;
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,NEW.catalog_item_id,true,
'F5 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F5 familia técnica: catálogo contextual automático',updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
DELETE FROM finding_catalog_asset_overrides
WHERE reason LIKE 'F% familia técnica:%'
`);
await queryRunner.query(`
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,mapping.catalog_item_id,true,$1::text,
asset.created_by,asset.updated_by
FROM assets asset
JOIN finding_catalog_item_inventory_families mapping
ON mapping.inventory_family_id=asset.inventory_family_id
WHERE asset.inventory_family_id IS NOT NULL
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason=$1::text,updated_at=CURRENT_TIMESTAMP
`,[F5_AUTO_REASON]);
}
private async restoreF31FamilySyncFunctions(queryRunner: QueryRunner):Promise<void> {
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_asset_inventory_family_catalog()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
DELETE FROM finding_catalog_asset_overrides
WHERE asset_id=NEW.id AND reason LIKE 'F3.1 familia técnica:%';
IF NEW.inventory_family_id IS NOT NULL THEN
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT NEW.id,mapping.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
NEW.created_by,NEW.updated_by
FROM finding_catalog_item_inventory_families mapping
WHERE mapping.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático',
updated_by=NEW.updated_by,updated_at=CURRENT_TIMESTAMP;
END IF;
RETURN NEW;
END $$;
`);
await queryRunner.query(`
CREATE OR REPLACE FUNCTION sync_inventory_family_mapping_assets()
RETURNS trigger LANGUAGE plpgsql AS $$
BEGIN
IF TG_OP='DELETE' THEN
DELETE FROM finding_catalog_asset_overrides override_record
USING assets asset
WHERE override_record.asset_id=asset.id
AND asset.inventory_family_id=OLD.inventory_family_id
AND override_record.catalog_item_id=OLD.catalog_item_id
AND override_record.reason LIKE 'F3.1 familia técnica:%';
RETURN OLD;
END IF;
INSERT INTO finding_catalog_asset_overrides(
asset_id,catalog_item_id,is_enabled,reason,created_by,updated_by
)
SELECT asset.id,NEW.catalog_item_id,true,
'F3.1 familia técnica: catálogo contextual automático',
asset.created_by,asset.updated_by
FROM assets asset
WHERE asset.inventory_family_id=NEW.inventory_family_id
ON CONFLICT (asset_id,catalog_item_id) DO UPDATE SET
is_enabled=true,reason='F3.1 familia técnica: catálogo contextual automático',
updated_at=CURRENT_TIMESTAMP;
RETURN NEW;
END $$;
`);
}
private async id(queryRunner: QueryRunner,sql:string,params:unknown[],label:string):Promise<string> {
const value=await this.optionalId(queryRunner,sql,params);
if (!value) throw new Error(`F5 could not resolve ${label}`);
return value;
}
private async optionalId(queryRunner: QueryRunner,sql:string,params:unknown[]):Promise<string|null> {
const rows=(await queryRunner.query(sql,params)) as IdRow[];
return rows[0]?.id ?? null;
}
}
@@ -1,41 +1,65 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { DataSource } from 'typeorm';
import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto';
import { FindingCatalogService } from './finding-catalog.service';
@Injectable()
export class F3FindingCatalogResolverService {
constructor(
private readonly dataSource: DataSource,
private readonly legacyCatalog: FindingCatalogService,
) {}
constructor(private readonly dataSource: DataSource) {}
async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) {
const [asset] = await this.dataSource.query(`
SELECT asset.id,asset.code,asset.name,
type.code AS "typeCode",
asset.inventory_family_id AS "familyId",
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel"
family.code AS "familyCode",family.name AS "familyName",family.level AS "familyLevel",
family.is_active AS "familyActive"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
LEFT JOIN inventory_families family ON family.id=asset.inventory_family_id
WHERE asset.id=$1::uuid
`, [assetId]) as Array<{
id: string;
code: string;
name: string;
typeCode: string;
familyId: string | null;
familyCode: string | null;
familyName: string | null;
familyLevel: string | null;
familyActive: boolean | null;
}>;
if (!asset) {
throw new NotFoundException({ code: 'ASSET_NOT_FOUND', message: 'Registro de Inventario no encontrado' });
}
if (!asset.familyId) return this.legacyCatalog.listApplicableForAsset(assetId, query);
const other = {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el Hallazgo no exista en el catálogo aplicable. Se enviará una propuesta a revisión de oficina.',
};
// F5: no se mezclan catálogos históricos. Yacimiento no posee familia técnica
// en final_modelov2.xlsx, por lo que puede registrar Hallazgos mediante OTROS.
if (!asset.familyId || asset.familyActive !== true) {
return {
asset: { id: asset.id, code: asset.code, name: asset.name },
inventoryFamily: null,
typeConfigured: false,
configurationReason: asset.typeCode.toLowerCase() === 'yacimiento'
? 'F5 · Yacimiento sin catálogo precargado: Hallazgos disponibles mediante OTROS.'
: 'F5 · El elemento todavía no tiene una clasificación técnica activa.',
categories: [],
items: [],
other,
};
}
const filters = [
'mapping.inventory_family_id=$1::uuid',
'item.is_active=true',
'category.is_active=true',
"lower(category.code)='f5model'",
'merge_record.source_item_id IS NULL',
];
const params: unknown[] = [asset.familyId];
@@ -83,15 +107,10 @@ export class F3FindingCatalogResolverService {
level: asset.familyLevel,
},
typeConfigured: true,
configurationReason: `F3.1 · Catálogo del Excel asociado a ${asset.familyName ?? 'la familia técnica'}`,
configurationReason: `F5 · Catálogo final_modelov2.xlsx asociado a ${asset.familyName ?? 'la clasificación técnica'}`,
categories,
items,
other: {
enabled: true,
code: 'OTHER',
label: 'OTROS',
help: 'Usalo cuando el hallazgo no exista en la familia técnica. Se enviará una propuesta a revisión de oficina.',
},
other,
};
}
}
@@ -122,7 +122,20 @@ export class F3FieldInventoryStructureService {
asset: { id: string; code: string; name: string };
[key: string]: unknown;
};
if (!family) return created;
// Área/Yacimiento remain structural context. A concrete Installation/Subinstallation
// created in field is a real Inventory instance from the moment it is registered.
await this.dataSource.query(`
UPDATE assets
SET is_inventory_instance=$3::boolean,updated_by=$2::uuid,updated_at=CURRENT_TIMESTAMP
WHERE id=$1::uuid
`, [
created.asset.id,
principal.userId,
typeCode === 'instalacion' || typeCode === 'subinstalacion',
]);
if (!family) return this.fieldInventory.detail(visitId, created.asset.id, principal);
await this.dataSource.transaction(async (manager) => {
const [current] = await manager.query(`
@@ -29,6 +29,7 @@ interface MobileVisitContext {
companyCode: string;
companyName: string;
assigned: boolean;
operatorRelationValid: boolean;
}
interface FieldDiscoveryCreated {
@@ -79,11 +80,23 @@ export class FieldInventoryService {
const context = await this.requireVisitContext(visitId, principal, false);
if (query.parentId) await this.requireParentInContext(query.parentId, context);
const args: unknown[] = [context.areaId, context.companyId, visitId];
// Empresa is visit context, never physical ownership. Search by Area ancestry.
const args: unknown[] = [context.areaId, visitId];
const conditions = [
'asset.operational_area_id = $1::uuid',
'asset.operator_company_id = $2::uuid',
"asset.information_status <> 'INACTIVE'",
`(
asset.operational_area_id = $1::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
)
SELECT 1 FROM ancestors WHERE id=$1::uuid LIMIT 1
)
)`,
"(asset.is_inventory_instance=true OR lower(type.code)='yacimiento')",
];
if (query.search?.trim()) {
@@ -129,13 +142,13 @@ export class FieldInventoryService {
) END AS parent,
EXISTS (
SELECT 1 FROM inspection_visit_assets link
WHERE link.visit_id = $3::uuid
WHERE link.visit_id = $2::uuid
AND link.asset_id = asset.id
AND link.included = true
) AS "selectedInInspection",
EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
) AS "captureRequired",
EXISTS (
SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id
@@ -143,26 +156,26 @@ export class FieldInventoryService {
(
SELECT COUNT(*)::integer
FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid
WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO'
) AS "fieldPhotoCount",
(
NOT EXISTS (
SELECT 1 FROM asset_field_discoveries discovery
WHERE discovery.visit_id = $3::uuid AND discovery.asset_id = asset.id
WHERE discovery.visit_id = $2::uuid AND discovery.asset_id = asset.id
)
OR (
EXISTS (SELECT 1 FROM asset_geometries geometry WHERE geometry.asset_id = asset.id)
AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid
WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'CREATED'
)
AND EXISTS (
SELECT 1 FROM asset_field_capture_events capture
WHERE capture.visit_id = $3::uuid
WHERE capture.visit_id = $2::uuid
AND capture.asset_id = asset.id
AND capture.event_type = 'PHOTO'
)
@@ -279,6 +292,7 @@ export class FieldInventoryService {
typeId: dto.typeId,
parentId,
operationalAreaId: context.areaId,
// Compatibility-only creation snapshot. Membership never depends on it.
operatorCompanyId: context.companyId,
description: dto.description ?? null,
discoveryNotes: dto.discoveryNotes ?? null,
@@ -413,7 +427,19 @@ export class FieldInventoryService {
AND member.user_id = $2::uuid
AND member.included = true
)
) AS assigned
) AS assigned,
EXISTS (
SELECT 1
FROM area_company_relations relation
WHERE relation.area_id=visit.operational_area_id
AND relation.company_id=visit.operator_company_id
AND relation.relation_role='OPERATOR'
AND relation.valid_from <= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
AND (
relation.valid_until IS NULL
OR relation.valid_until >= COALESCE(visit.actual_started_at,visit.planned_start_at,visit.created_at)
)
) AS "operatorRelationValid"
FROM inspection_visits visit
LEFT JOIN assets area ON area.id = visit.operational_area_id
LEFT JOIN assets company ON company.id = visit.operator_company_id
@@ -429,6 +455,12 @@ export class FieldInventoryService {
message: 'La inspección no tiene Área y Operadora definidas',
});
}
if (!context.operatorRelationValid) {
throw new ConflictException({
code: 'FIELD_INVENTORY_OPERATOR_RELATION_INVALID',
message: 'La Operadora seleccionada no estaba vinculada al Área para la fecha de esta inspección',
});
}
if (!context.assigned) {
throw new ConflictException({
code: 'FIELD_INVENTORY_INSPECTOR_NOT_ASSIGNED',
@@ -469,18 +501,27 @@ export class FieldInventoryService {
asset.code,
asset.name,
asset.asset_type_id AS "typeId",
asset.operational_area_id AS "areaId",
asset.operator_company_id AS "companyId"
(
asset.id=$2::uuid
OR asset.operational_area_id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideArea"
FROM assets asset
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [parentId])) as Array<{
`, [parentId, context.areaId])) as Array<{
id: string;
code: string;
name: string;
typeId: string;
areaId: string | null;
companyId: string | null;
insideArea: boolean;
}>;
if (!parent) {
@@ -489,13 +530,10 @@ export class FieldInventoryService {
message: 'La ubicación padre no existe',
});
}
if (
parent.id !== context.areaId
&& (parent.areaId !== context.areaId || parent.companyId !== context.companyId)
) {
if (!parent.insideArea) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_PARENT_OUTSIDE_CONTEXT',
message: 'La ubicación padre no pertenece al Área y Operadora de la inspección',
message: 'La ubicación padre no pertenece al Área de la inspección',
});
}
return parent;
@@ -503,16 +541,28 @@ export class FieldInventoryService {
private async requireAssetInContext(assetId: string, context: MobileVisitContext) {
const [asset] = (await this.dataSource.query(`
SELECT id,
operational_area_id AS "areaId",
operator_company_id AS "companyId"
FROM assets
WHERE id = $1::uuid
AND information_status <> 'INACTIVE'
`, [assetId])) as Array<{
SELECT
asset.id,
(asset.is_inventory_instance=true OR lower(type.code)='yacimiento') AS "eligibleTarget",
(
asset.operational_area_id=$2::uuid
OR EXISTS (
WITH RECURSIVE ancestors AS (
SELECT id,parent_id FROM assets WHERE id=asset.parent_id
UNION ALL
SELECT parent.id,parent.parent_id
FROM assets parent JOIN ancestors child ON parent.id=child.parent_id
) SELECT 1 FROM ancestors WHERE id=$2::uuid LIMIT 1
)
) AS "insideArea"
FROM assets asset
JOIN asset_types type ON type.id=asset.asset_type_id
WHERE asset.id = $1::uuid
AND asset.information_status <> 'INACTIVE'
`, [assetId, context.areaId])) as Array<{
id: string;
areaId: string | null;
companyId: string | null;
eligibleTarget: boolean;
insideArea: boolean;
}>;
if (!asset) {
@@ -521,10 +571,16 @@ export class FieldInventoryService {
message: 'Registro de Inventario no encontrado',
});
}
if (asset.areaId !== context.areaId || asset.companyId !== context.companyId) {
if (!asset.insideArea) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_OUTSIDE_CONTEXT',
message: 'El registro no pertenece al Área y Operadora de esta inspección',
message: 'El registro no pertenece al Área de esta inspección',
});
}
if (!asset.eligibleTarget) {
throw new BadRequestException({
code: 'FIELD_INVENTORY_NOT_OPERATIONAL_TARGET',
message: 'El registro no es un Yacimiento ni una instancia real de Inventario',
});
}
return asset;
File diff suppressed because one or more lines are too long
+2 -2
View File
@@ -1,2 +1,2 @@
export const API_VERSION = '0.25.0-1';
export const API_PHASE = 'F4';
export const API_VERSION = '0.26.0-1';
export const API_PHASE = 'F5';
@@ -7,13 +7,15 @@ async function source(relativePath: string): Promise<string> {
return readFile(resolve(process.cwd(), 'src', relativePath), 'utf8');
}
test('D5.3.11.1 exposes imported operational assignments in Maestro navigation without creating legal relations', async () => {
test('D5.3.11.1/F5 navigates Area ↔ Empresa from temporal operator relations instead of Inventory ownership', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /SELECT asset\.operator_company_id AS company_id/);
assert.match(service, /WHERE asset\.operational_area_id = \$1/);
assert.match(service, /SELECT asset\.operational_area_id AS area_id/);
assert.match(service, /WHERE asset\.operator_company_id = \$1/);
assert.match(service, /UNION/);
assert.match(service, /FROM area_company_relations relation/);
assert.match(service, /WHERE relation\.area_id = \$1/);
assert.match(service, /WHERE relation\.company_id = \$1/);
assert.match(service, /relation\.relation_role = 'OPERATOR'/);
assert.match(service, /relation\.valid_until IS NULL/);
assert.doesNotMatch(service, /SELECT asset\.operator_company_id AS company_id/);
assert.doesNotMatch(service, /SELECT asset\.operational_area_id AS area_id/);
assert.doesNotMatch(service, /INSERT INTO area_company_relations[\s\S]*listAreasForCompany/);
});
@@ -33,11 +33,13 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry',
assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/);
});
test('D5.3 relation lifecycle is historical and audited', async () => {
test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => {
const service = await source('asset-master/asset-operational-relations.service.ts');
assert.match(service, /INSERT INTO area_company_relations/);
assert.match(service, /valid_until = CURRENT_TIMESTAMP/);
assert.match(service, /AREA_COMPANY_RELATION_IN_USE/);
assert.match(service, /Ending an operator relation must never be blocked by existing Inventory/);
assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/);
assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/);
assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/);
});
+17 -14
View File
@@ -2,27 +2,28 @@ import { strict as assert } from 'node:assert';
import test from 'node:test';
import { DashboardService } from '../../src/dashboard/dashboard.service';
test('DashboardService returns F4 operational counts and recent activity consistently', async () => {
test('DashboardService returns F5 operational counts and inspector activity consistently', async () => {
const recent = [{
id: 'event-id',
occurredAt: new Date('2026-08-13T12:00:00.000Z'),
actorUsername: 'admin',
action: 'AUTH_LOGIN_SUCCESS',
entityType: 'auth_session',
entityId: 'session-id',
occurredAt: new Date('2026-09-08T12:00:00.000Z'),
actorUsername: 'inspector1',
action: 'INSPECTION_STARTED',
entityType: 'inspection_visit',
entityId: 'visit-id',
}];
const manager = {
query: async (sql: string) => {
if (sql.includes('COUNT(*) FROM users')) {
return [{
activeUsers: '4', inactiveUsers: '2', activeSessions: '3',
totalAssets: '12', assetsNeedValidation: '4', assetsWithoutGeometry: '6', plannedInspections: '2',
openFindings: '8', findingsWithoutControlDate: '5', overdueControls: '2', controlsNext30Days: '3',
totalAssets: '0', assetsNeedValidation: '0', assetsWithoutGeometry: '0', plannedInspections: '2',
openFindings: '8', actsInFollowUp: '6', findingsWithoutControlDate: '5', overdueControls: '2', controlsNext30Days: '3',
reportsWorking: '1', reportsOfficialized: '2', sealedActsWithoutReport: '4',
}];
}
if (sql.includes('FROM inspection_findings finding')) return [];
return recent;
if (sql.includes('FROM audit_events event')) return recent;
if (sql.includes('next_control_on AS "nextControlOn"')) return [];
throw new Error(`Unexpected DashboardService query in test: ${sql}`);
},
};
const dataSource = {
@@ -31,14 +32,15 @@ test('DashboardService returns F4 operational counts and recent activity consist
const result = await new DashboardService(dataSource as never).summary();
assert.deepEqual(result.counts, {
totalAssets: 12,
assetsNeedValidation: 4,
assetsWithoutGeometry: 6,
totalAssets: 0,
assetsNeedValidation: 0,
assetsWithoutGeometry: 0,
plannedInspections: 2,
activeUsers: 4,
inactiveUsers: 2,
activeSessions: 3,
openFindings: 8,
actsInFollowUp: 6,
findingsWithoutControlDate: 5,
overdueControls: 2,
controlsNext30Days: 3,
@@ -46,7 +48,8 @@ test('DashboardService returns F4 operational counts and recent activity consist
reportsOfficialized: 2,
sealedActsWithoutReport: 4,
});
assert.equal(result.recentAudit, recent);
assert.deepEqual(result.recentInspectorActivity, recent);
assert.equal('recentAudit' in result, false);
assert.deepEqual(result.upcomingControls, []);
assert.equal(typeof result.generatedAt, 'string');
});
@@ -19,12 +19,14 @@ test('F3.1 registra cada fusión como evento append-only con snapshots', () => {
assert.match(migration, /append-only/);
});
test('F3.1 sólo permite fusionar Instalaciones o Subinstalaciones compatibles', () => {
test('F3.1/F5 sólo permite fusionar Instalaciones o Subinstalaciones compatibles dentro de la misma Área física', () => {
assert.match(service, /MERGEABLE_TYPES = new Set\(\['instalacion', 'subinstalacion'\]\)/);
assert.match(service, /INVENTORY_MERGE_TYPE_INVALID/);
assert.match(service, /INVENTORY_MERGE_CONTEXT_MISMATCH/);
assert.match(service, /resolvePhysicalAreaId/);
assert.match(service, /INVENTORY_MERGE_AREA_MISMATCH/);
assert.match(service, /INVENTORY_MERGE_PARENT_MISMATCH/);
assert.match(service, /INVENTORY_MERGE_CHILD_FAMILY_CONFLICT/);
assert.doesNotMatch(service, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
});
test('F3.1 preserva referencias históricas y sólo reubica hijos actuales', () => {
+3 -3
View File
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import test from 'node:test';
import { API_PHASE, API_VERSION } from '../../src/version';
test('F4 health metadata reports the deployed phase without changing the release version', () => {
assert.equal(API_PHASE, 'F4');
assert.equal(API_VERSION, '0.25.0-1');
test('health metadata reports the current F5 release', () => {
assert.equal(API_PHASE, 'F5');
assert.equal(API_VERSION, '0.26.0-1');
});
@@ -0,0 +1,143 @@
import assert from 'node:assert/strict';
import { readFileSync } from 'node:fs';
import test from 'node:test';
import { loadF5InventoryAuthoritativeSource } from '../../src/reference-data/f5-authoritative-inventory-source';
function key(value: string): string {
return value.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, ' ')
.trim()
.replace(/\s+/g, ' ');
}
test('F5 territory preload is exactly the authorized Tablas de yacimiento y areas workbook', () => {
const source = loadF5InventoryAuthoritativeSource().areaSource;
assert.equal(source.file, 'Tablas de yacimiento y areas.xlsx');
assert.equal(source.sheet, 'cr26e_tabla1');
assert.equal(source.sha256, '8260fcadebbcd631a4c95260d0a67c3ecb28d497b32decb02a1c0847be5afa78');
assert.equal(source.rows.length, 230);
const areas = new Set(source.rows.map((row) => key(row.area)));
const literalYacimientoNames = new Set(source.rows.map((row) => row.yacimiento.trim()));
const yacimientos = new Set(source.rows.map((row) => key(row.yacimiento)));
const areaYacimiento = new Set(source.rows.map((row) => `${key(row.area)}|${key(row.yacimiento)}`));
const departments = new Set(source.rows.map((row) => key(row.departamento)));
const concessionTypes = new Set(source.rows.map((row) => key(row.tipoConcesion)));
const operators = new Set(source.rows.map((row) => key(row.empresaOperadora)));
assert.equal(areas.size, 64);
// The authorized workbook contains 220 literal Yacimiento names. The F5 key
// intentionally folds accents/punctuation/case, so one pair of literal names
// collides into the same normalized key. Yacimiento identity is Area+Yacimiento,
// never a globally-unique normalized name.
assert.equal(literalYacimientoNames.size, 220);
assert.equal(yacimientos.size, 219);
assert.equal(areaYacimiento.size, 230);
assert.equal(departments.size, 7);
assert.equal(concessionTypes.size, 2);
assert.equal(operators.size, 13);
const literalNamesByKey = new Map<string, Set<string>>();
for (const row of source.rows) {
const normalized = key(row.yacimiento);
const literals = literalNamesByKey.get(normalized) ?? new Set<string>();
literals.add(row.yacimiento.trim());
literalNamesByKey.set(normalized, literals);
}
assert.equal([...literalNamesByKey.values()].filter((items) => items.size > 1).length, 1);
const literalAreasByYacimiento = new Map<string, Set<string>>();
const areasByYacimiento = new Map<string, Set<string>>();
for (const row of source.rows) {
const literal = row.yacimiento.trim();
const literalMemberships = literalAreasByYacimiento.get(literal) ?? new Set<string>();
literalMemberships.add(key(row.area));
literalAreasByYacimiento.set(literal, literalMemberships);
const normalized = key(row.yacimiento);
const memberships = areasByYacimiento.get(normalized) ?? new Set<string>();
memberships.add(key(row.area));
areasByYacimiento.set(normalized, memberships);
}
const literalMembershipSurplus = [...literalAreasByYacimiento.values()]
.reduce((total, memberships) => total + Math.max(0, memberships.size - 1), 0);
const normalizedMembershipSurplus = [...areasByYacimiento.values()]
.reduce((total, memberships) => total + Math.max(0, memberships.size - 1), 0);
assert.equal(literalMembershipSurplus, 10);
assert.equal(normalizedMembershipSurplus, 11);
for (const area of areas) {
const rows = source.rows.filter((row) => key(row.area) === area);
assert.equal(new Set(rows.map((row) => key(row.departamento))).size, 1, `${area}: departamento`);
assert.equal(new Set(rows.map((row) => key(row.tipoConcesion))).size, 1, `${area}: concesión`);
assert.equal(new Set(rows.map((row) => key(row.empresaOperadora))).size, 1, `${area}: operadora`);
}
});
test('F5 technical catalog is exactly final_modelov2 and resolves internal IDEM references', () => {
const source = loadF5InventoryAuthoritativeSource().catalogSource;
assert.equal(source.file, 'final_modelov2.xlsx');
assert.equal(source.sheet, 'Hoja1');
assert.equal(source.sha256, 'c9a2d1db59fff2157162c41009b8c9042a3c7a3001649239a07732a3b8fca155');
assert.equal(source.installations.length, 14);
assert.equal(source.subinstallations.length, 109);
const installationKeys = new Set(source.installations.map((item) => key(item.name)));
assert.equal(installationKeys.size, 14);
for (const subinstallation of source.subinstallations) {
assert.ok(installationKeys.has(key(subinstallation.installation)), `${subinstallation.name} parent`);
assert.ok(subinstallation.findings.every((finding) => !/^idem\b/i.test(finding.trim())), `${subinstallation.name} has unresolved IDEM`);
}
for (const installation of source.installations) {
assert.ok(installation.findings.every((finding) => !/^idem\b/i.test(finding.trim())), `${installation.name} has unresolved IDEM`);
}
const allFindings = new Map<string, string>();
for (const finding of source.universalFindings) allFindings.set(key(finding), finding);
for (const installation of source.installations) {
for (const finding of installation.findings) allFindings.set(key(finding), finding);
}
for (const subinstallation of source.subinstallations) {
for (const finding of subinstallation.findings) allFindings.set(key(finding), finding);
}
assert.equal(allFindings.size, 177);
assert.deepEqual(
new Set(source.universalFindings.map(key)),
new Set([
'ORDEN Y LIMPIEZA',
'CARTELERIA PREVENTIVA / INFORMATIVA',
'EXTINTORES',
].map(key)),
);
});
test('F5 source contracts keep Empresa out of physical ownership and preserve sealed documents on merge', () => {
const mergeSource = readFileSync('src/asset-master/inventory-merge.service.ts', 'utf8');
const fieldSource = readFileSync('src/inspection-visits/field-inventory.service.ts', 'utf8');
const contextMigration = readFileSync('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts', 'utf8');
const catalogMigration = readFileSync('src/database/migrations/1790087300000-f5-authoritative-inventory-catalog.ts', 'utf8');
assert.match(mergeSource, /resolvePhysicalAreaId/);
assert.match(mergeSource, /documentInvariantsBefore/);
assert.match(mergeSource, /locked_sha256/);
assert.match(mergeSource, /closure_sha256/);
assert.match(mergeSource, /frozen_sha256/);
assert.match(mergeSource, /gedo_pdf_sha256/);
assert.match(mergeSource, /historicalReferencesRewritten:\s*false/);
assert.doesNotMatch(mergeSource, /source\.operatorCompanyId\s*!==\s*canonical\.operatorCompanyId/);
assert.doesNotMatch(mergeSource, /misma Área y Operadora/);
assert.match(fieldSource, /Área de esta inspección/);
assert.match(fieldSource, /area_company_relations/);
assert.doesNotMatch(fieldSource, /asset\.operator_company_id\s*=\s*\$2::uuid/);
assert.match(contextMigration, /public async up[\s\S]*installAreaOwnedGuard/);
assert.match(contextMigration, /public async down[\s\S]*installLegacyPairedGuard/);
assert.match(contextMigration, /La Empresa se cambia en la relación temporal del Área/);
assert.doesNotMatch(catalogMigration, /unaccent\s*\(/i);
assert.doesNotMatch(catalogMigration, /source_reference\s+IS\s+NULL/i);
assert.match(catalogMigration, /F5_SOURCE_FAMILY_COUNT\s*=\s*123/);
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-web",
"version": "0.20.0-4",
"version": "0.21.0-1",
"private": true,
"type": "module",
"engines": {
-4
View File
@@ -18,7 +18,6 @@ import { AssetEditorPage } from '../pages/AssetEditorPage';
import { InventoryCreatePage } from '../pages/InventoryCreatePage';
import { FieldDiscoveriesPage } from '../pages/FieldDiscoveriesPage';
import { AssetTypesPage } from '../pages/AssetTypesPage';
import { InventoryFunctionsPage } from '../pages/InventoryFunctionsPage';
import { HistoryPage } from '../pages/HistoryPage';
import { TemporalAssetsPage } from '../pages/TemporalAssetsPage';
import { InspectionVisitsPage } from '../pages/InspectionVisitsPage';
@@ -27,7 +26,6 @@ import { InspectionActEditorPage } from '../pages/InspectionActEditorPage';
import { FindingCatalogPage } from '../pages/FindingCatalogPage';
import { FindingDetailPage } from '../pages/FindingDetailPage';
import { FindingsPage } from '../pages/FindingsPage';
import { AssetImportsPage } from '../pages/AssetImportsPage';
import { ActsPage } from '../pages/ActsPage';
import { ReportsPage } from '../pages/ReportsPage';
import { ReportDetailPage } from '../pages/ReportDetailPage';
@@ -55,7 +53,6 @@ export function App() {
<Route path="/activos/:id" element={<AssetEditorPage />} />
</Route>
<Route element={<PermissionRoute permission="assets.change_status" />}><Route path="/inventarios/revision-campo" element={<FieldDiscoveriesPage />} /></Route>
<Route element={<PermissionRoute permission="asset_imports.read" />}><Route path="/importaciones" element={<AssetImportsPage />} /></Route>
<Route element={<PermissionRoute permission="assets.create" />}><Route path="/inventarios/nuevo" element={<InventoryCreatePage />} /><Route path="/activos/nuevo" element={<Navigate to="/inventarios/nuevo" replace />} /></Route>
<Route path="/planificacion" element={<Navigate to="/inspecciones?status=PLANNED" replace />} />
<Route element={<PermissionRoute permission="inspections.read" />}>
@@ -82,7 +79,6 @@ export function App() {
<Route element={<PermissionRoute permission="roles.read" />}><Route path="/admin/roles" element={<RolesPage />} /></Route>
<Route element={<PermissionRoute permission="audit.read" />}><Route path="/admin/audit" element={<AuditPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.read" />}><Route path="/admin/asset-types" element={<AssetTypesPage />} /></Route>
<Route element={<PermissionRoute permission="asset_types.manage" />}><Route path="/admin/inventory-functions" element={<InventoryFunctionsPage />} /></Route>
<Route element={<PermissionRoute permission="finding_catalog.manage" />}><Route path="/admin/finding-catalog" element={<FindingCatalogPage />} /></Route>
<Route element={<PermissionRoute permission="document_delivery.read" />}><Route path="/admin/document-delivery" element={<DocumentDeliveryPage />} /></Route>
<Route path="/sin-acceso" element={<AccessDeniedPage />} />
+1 -1
View File
@@ -1,2 +1,2 @@
export const APP_VERSION = '0.21.0-1';
export const APP_PHASE = 'Fase F3.1 · Inventario estructural y catálogo contextual';
export const APP_PHASE = 'F5 · Inventario operativo y catálogo autorizado';
+90
View File
@@ -0,0 +1,90 @@
/* F5 · ajustes visuales acotados a los flujos saneados. */
.status-tabs {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin: 0 0 18px;
padding: 7px;
border: 1px solid var(--border, #e2e8f0);
border-radius: 14px;
background: var(--surface, #fff);
box-shadow: 0 4px 14px rgba(15, 23, 42, 0.04);
}
.status-tabs button {
appearance: none;
display: inline-flex;
align-items: center;
gap: 7px;
min-height: 38px;
padding: 8px 12px;
border: 1px solid transparent;
border-radius: 10px;
background: transparent;
color: var(--text-muted, #64748b);
font: inherit;
font-size: 13px;
font-weight: 700;
line-height: 1;
cursor: pointer;
transition: background .15s ease, border-color .15s ease, color .15s ease, box-shadow .15s ease;
}
.status-tabs button:hover {
background: #f8fafc;
border-color: #e2e8f0;
color: #0f172a;
}
.status-tabs button.active {
background: #eef4ff;
border-color: #bfd2fb;
color: #173d7a;
box-shadow: inset 0 0 0 1px rgba(59, 130, 246, 0.04);
}
.status-tabs button small {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 22px;
height: 22px;
padding: 0 6px;
border-radius: 999px;
background: #edf2f7;
color: #475569;
font-size: 11px;
font-weight: 800;
}
.status-tabs button.active small {
background: #d7e5ff;
color: #173d7a;
}
.asset-browser-item-status > strong {
display: block;
font-size: 18px;
line-height: 1;
text-align: right;
}
.asset-browser-item-status > small {
display: block;
margin-top: 5px;
}
@media (max-width: 720px) {
.status-tabs {
flex-wrap: nowrap;
overflow-x: auto;
scrollbar-width: thin;
padding-bottom: 9px;
}
.status-tabs button {
flex: 0 0 auto;
white-space: nowrap;
}
}
+127 -184
View File
@@ -1,108 +1,87 @@
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useState } from 'react';
import { Link, useSearchParams } from 'react-router';
import { useAuth } from '../../auth/AuthContext';
import { Alert, EmptyState, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getAssetLineage } from '../../lib/api';
import type { AssetLineageItem } from '../../lib/api';
import {
getAsset,
getAssetLineage,
listAreasForCompany,
listCompaniesForArea,
listAssetTreeChildren,
listOperationalAreas,
listOperationalCompanies,
} from '../../lib/api';
import type { AssetDetail, AssetLineageItem, AssetListItem, OperationalAssetSummary } from '../../lib/api';
listInventoryAreas,
listInventoryChildren,
} from '../../lib/inventoryBrowserApi';
import type {
InventoryBrowserArea,
InventoryBrowserItem,
InventoryQuery,
} from '../../lib/inventoryBrowserApi';
import { assetOperationalStatusLabel, assetStatusClass, assetStatusLabel } from './assetPresentation';
type TreeFilters = Omit<NonNullable<Parameters<typeof listAssetTreeChildren>[0]>, 'parentId' | 'limit'>;
type NavigationSection = 'companies' | 'territory';
type ChildGroupKey = 'fields' | 'installations' | 'wells' | 'equipment';
const INSTALLATION_CODES = new Set([
'estructura_local', 'locacion', 'instalacion', 'planta', 'bateria', 'estacion', 'subestacion',
'zona_bombas', 'sistema_drenaje', 'sistema_electrico_iluminacion', 'sistema_defensa_incendios',
'pileta_api', 'cargadero_descargadero',
]);
const GROUP_ORDER: ChildGroupKey[] = ['fields', 'installations', 'wells', 'equipment'];
const GROUP_LABELS: Record<ChildGroupKey, { title: string; description: string }> = {
fields: { title: 'Yacimientos', description: 'Unidades territoriales u operativas dentro del Área.' },
installations: { title: 'Instalaciones y estructura', description: 'Plantas, baterías, estaciones, locaciones y niveles estructurales.' },
wells: { title: 'Pozos', description: 'Pozos identificados dentro del contexto seleccionado.' },
equipment: { title: 'Equipos y otros elementos', description: 'Equipos técnicos y demás elementos registrados en el inventario.' },
};
function childGroup(item: AssetListItem): ChildGroupKey {
const code = item.type.code.toLowerCase();
if (code === 'yacimiento') return 'fields';
if (INSTALLATION_CODES.has(code)) return 'installations';
if (code === 'pozo') return 'wells';
return 'equipment';
}
function normalizeSearch(value: string | undefined) {
return value?.trim().toLocaleLowerCase('es-AR') ?? '';
}
function matchesSearch(item: { name: string; code: string; commonName?: string | null }, search?: string) {
if (!search) return true;
const term = normalizeSearch(search);
return item.name.toLocaleLowerCase('es-AR').includes(term) || item.code.toLocaleLowerCase('es-AR').includes(term) || Boolean(item.commonName?.toLocaleLowerCase('es-AR').includes(term));
}
function navigationHref(base: URLSearchParams, section: NavigationSection, options: { companyId?: string; parentId?: string } = {}) {
function navigationHref(base: URLSearchParams, parentId?: string) {
const params = new URLSearchParams(base);
params.delete('view');
params.delete('page');
params.delete('operationalAreaId');
params.delete('operatorCompanyId');
params.set('section', section);
options.companyId ? params.set('companyId', options.companyId) : params.delete('companyId');
options.parentId ? params.set('parentId', options.parentId) : params.delete('parentId');
return `/inventarios?${params}`;
params.delete('section');
params.delete('companyId');
parentId ? params.set('parentId', parentId) : params.delete('parentId');
return `/inventarios${params.size ? `?${params}` : ''}`;
}
function AssetCard({ item, href }: { item: AssetListItem; href: string }) {
function AreaCard({ area, href }: { area: InventoryBrowserArea; href: string }) {
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="layers" size={17} /></span>
<span className="asset-browser-item-icon"><Icon name="map" size={17} /></span>
<span className="asset-browser-item-main">
<strong>{item.name}</strong>
<small>{item.code} · {item.type.name}{item.commonName ? ` · ${item.commonName}` : ''}</small>
<strong>{area.name}</strong>
<small>
{area.code} · {area.yacimientoCount} yacimiento{area.yacimientoCount === 1 ? '' : 's'}
{area.currentOperator ? ` · Operadora vigente: ${area.currentOperator.name}` : ' · Sin operadora vigente'}
</small>
</span>
<span className="asset-browser-item-status">
<span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span>
<small>{assetOperationalStatusLabel(item.operationalStatus)}</small>
<strong>{area.inventoryCount}</strong>
<small>instancia{area.inventoryCount === 1 ? '' : 's'} real{area.inventoryCount === 1 ? '' : 'es'}</small>
</span>
<Icon name="chevron" size={16} />
</Link>;
}
function SummaryCard({ item, href, subtitle }: { item: OperationalAssetSummary; href: string; subtitle: string }) {
function InventoryCard({ item, href }: { item: InventoryBrowserItem; href: string }) {
const structural = !item.isInventoryInstance;
return <Link className="asset-browser-item" to={href}>
<span className="asset-browser-item-icon"><Icon name="layers" size={17} /></span>
<span className="asset-browser-item-icon"><Icon name={structural ? 'map' : 'layers'} size={17} /></span>
<span className="asset-browser-item-main">
<strong>{item.name}</strong>
<small>{item.code} · {subtitle}{item.commonName ? ` · ${item.commonName}` : ''}</small>
<small>
{item.code} · {item.type.name}
{item.inventoryFamily ? ` · ${item.inventoryFamily.name}` : ''}
{item.commonName ? ` · ${item.commonName}` : ''}
</small>
</span>
<span className="asset-browser-item-status">
{structural
? <><span className="tag">Contexto</span><small>{item.childrenCount} nivel{item.childrenCount === 1 ? '' : 'es'} inferior{item.childrenCount === 1 ? '' : 'es'}</small></>
: <><span className={`status-badge ${assetStatusClass(item.informationStatus)}`}>{assetStatusLabel(item.informationStatus)}</span><small>{assetOperationalStatusLabel(item.operationalStatus)}</small></>}
</span>
<Icon name="chevron" size={16} />
</Link>;
}
export function AssetHierarchyView({ filters }: { filters: TreeFilters }) {
function nextLevelLabel(typeCode: string | undefined) {
switch (typeCode?.toLowerCase()) {
case 'area': return 'Yacimientos';
case 'yacimiento': return 'Instalaciones';
case 'instalacion': return 'Subinstalaciones';
default: return 'Niveles inferiores';
}
}
export function AssetHierarchyView({ filters }: { filters: InventoryQuery }) {
const { hasPermission } = useAuth();
const canCreate = hasPermission('assets.create');
const [searchParams] = useSearchParams();
const rawSection = searchParams.get('section');
const section: NavigationSection | null = rawSection === 'companies' || rawSection === 'territory' ? rawSection : null;
const companyId = searchParams.get('companyId') ?? '';
const parentId = searchParams.get('parentId') ?? '';
const [companies, setCompanies] = useState<OperationalAssetSummary[]>([]);
const [areas, setAreas] = useState<OperationalAssetSummary[]>([]);
const [company, setCompany] = useState<AssetDetail | null>(null);
const [areas, setAreas] = useState<InventoryBrowserArea[]>([]);
const [children, setChildren] = useState<InventoryBrowserItem[]>([]);
const [lineage, setLineage] = useState<AssetLineageItem[]>([]);
const [children, setChildren] = useState<AssetListItem[]>([]);
const [hasMore, setHasMore] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
@@ -111,146 +90,110 @@ export function AssetHierarchyView({ filters }: { filters: TreeFilters }) {
let active = true;
setLoading(true);
setError('');
setCompanies([]);
setAreas([]);
setCompany(null);
setLineage([]);
setChildren([]);
setLineage([]);
setHasMore(false);
const run = async () => {
if ((!section || section === 'companies') && !companyId && !parentId) {
const loaded = filters.operationalAreaId ? await listCompaniesForArea(filters.operationalAreaId) : await listOperationalCompanies();
if (active) setCompanies(loaded.filter((item) => (!filters.operatorCompanyId || item.id === filters.operatorCompanyId) && matchesSearch(item, filters.search)));
if (!parentId) {
const response = await listInventoryAreas({
search: filters.search,
operationalAreaId: filters.operationalAreaId,
operatorCompanyId: filters.operatorCompanyId,
});
if (active) setAreas(response.data);
return;
}
if (section === 'companies' && companyId && !parentId) {
const [loadedCompany, loadedAreas] = await Promise.all([getAsset(companyId), listAreasForCompany(companyId)]);
if (!active) return;
setCompany(loadedCompany);
setAreas(loadedAreas.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search)));
return;
}
if (section === 'territory' && !parentId) {
const loaded = await listOperationalAreas();
if (active) setAreas(loaded.filter((item) => (!filters.operationalAreaId || item.id === filters.operationalAreaId) && matchesSearch(item, filters.search)));
return;
}
if (parentId) {
const [loadedLineage, childResponse, loadedCompany] = await Promise.all([
getAssetLineage(parentId),
listAssetTreeChildren({
...filters,
operatorCompanyId: companyId || filters.operatorCompanyId,
parentId,
limit: 200,
}),
companyId ? getAsset(companyId) : Promise.resolve(null),
]);
if (!active) return;
setLineage(loadedLineage);
setChildren(childResponse.data);
setHasMore(childResponse.meta.hasMore);
setCompany(loadedCompany);
}
const [loadedLineage, response] = await Promise.all([
getAssetLineage(parentId),
listInventoryChildren(parentId, { search: filters.search }),
]);
if (!active) return;
setLineage(loadedLineage.filter((item) => ['area','yacimiento','instalacion','subinstalacion'].includes(item.type.code.toLowerCase())));
setChildren(response.data);
setHasMore(response.meta.hasMore);
};
run().catch((requestError) => active && setError(errorMessage(requestError))).finally(() => active && setLoading(false));
run()
.catch((requestError) => active && setError(errorMessage(requestError)))
.finally(() => active && setLoading(false));
return () => { active = false; };
}, [section, companyId, parentId, JSON.stringify(filters)]);
const groupedChildren = useMemo(() => {
const groups = new Map<ChildGroupKey, AssetListItem[]>();
children.forEach((item) => {
const key = childGroup(item);
groups.set(key, [...(groups.get(key) ?? []), item]);
});
return GROUP_ORDER.map((key) => ({ key, items: groups.get(key) ?? [] })).filter((group) => group.items.length > 0);
}, [children]);
const current = lineage.at(-1) ?? null;
const activeSection: NavigationSection = section ?? 'companies';
}, [parentId, filters.search, filters.operationalAreaId, filters.operatorCompanyId]);
if (loading) return <LoadingBlock label="Cargando inventarios…" />;
if (!section && !companyId && !parentId) {
if (!parentId) {
const realTotal = areas.reduce((sum, area) => sum + Number(area.inventoryCount ?? 0), 0);
return <div className="asset-browser-panel">
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading">
<div><span className="eyebrow">INVENTARIOS POR EMPRESA</span><h2>Elegí una empresa</h2><p>Cada empresa tiene su propio inventario. Ingresá para recorrer Áreas, Yacimientos, instalaciones y equipos.</p></div>
<div className="asset-browser-current-actions"><Link className="button secondary" to={navigationHref(searchParams, 'territory')}><Icon name="map" />Vista territorial</Link><span className="count-pill">{companies.length}</span></div>
<div>
<span className="eyebrow">ESTRUCTURA TERRITORIAL</span>
<h2>Áreas</h2>
<p>Las Áreas y Yacimientos son contexto de navegación. El Inventario real comienza en las Instalaciones/Subinstalaciones efectivamente registradas.</p>
</div>
<div className="asset-browser-current-actions">
<span className="count-pill">{realTotal} Inventario real</span>
<span className="count-pill">{areas.length} Áreas</span>
</div>
</div>
{companies.length === 0 ? <EmptyState title="No hay inventarios para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{companies.map((item) => <SummaryCard key={item.id} item={item} subtitle="Inventario de empresa" href={navigationHref(searchParams, 'companies', { companyId: item.id })} />)}</div>}
<div className="asset-browser-levels" aria-label="Estructura de los inventarios">
<div><span>1</span><strong>Empresa</strong><small>Inventario principal</small></div><i></i>
<div><span>2</span><strong>Área</strong><small>Contexto territorial</small></div><i></i>
<div><span>3</span><strong>Yacimiento</strong><small>Nivel territorial</small></div><i></i>
<div><span>4</span><strong>Instalación</strong><small>Planta, batería, estación</small></div><i></i>
<div><span>5</span><strong>Equipo</strong><small>Equipo, pozo, tanque</small></div>
{areas.length === 0
? <EmptyState title="No hay Áreas para mostrar" text="Probá con otra búsqueda o revisá el contexto seleccionado." />
: <div className="asset-browser-list">{areas.map((area) => <AreaCard key={area.id} area={area} href={navigationHref(searchParams, area.id)} />)}</div>}
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Área</strong><small>Ancla territorial</small></div><i></i>
<div><span>2</span><strong>Yacimiento</strong><small>Contexto dentro del Área</small></div><i></i>
<div><span>3</span><strong>Instalación</strong><small>Inventario real</small></div><i></i>
<div><span>4</span><strong>Subinstalación</strong><small>Inventario real</small></div>
</div>
</div>;
}
const breadcrumb = <nav className="asset-browser-breadcrumb" aria-label="Ruta del inventario">
const current = lineage.at(-1) ?? null;
const breadcrumb = <nav className="asset-browser-breadcrumb" aria-label="Ruta del Inventario">
<Link to="/inventarios">Inventarios</Link>
{activeSection === 'territory' && <><span></span><Link to={navigationHref(searchParams, 'territory')}>Vista territorial</Link></>}
{company && <><span></span>{parentId ? <Link to={navigationHref(searchParams, 'companies', { companyId: company.id })}>{company.name}</Link> : <strong>{company.name}</strong>}</>}
{lineage.map((item, index) => {
const isLast = index === lineage.length - 1;
return <span className="asset-browser-crumb-part" key={item.id}><span></span>{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams, activeSection, { companyId: companyId || undefined, parentId: item.id })}>{item.name}</Link>}</span>;
{lineage.map((item,index) => {
const isLast=index===lineage.length-1;
return <span className="asset-browser-crumb-part" key={item.id}>
<span></span>
{isLast ? <strong>{item.name}</strong> : <Link to={navigationHref(searchParams,item.id)}>{item.name}</Link>}
</span>;
})}
</nav>;
if (section === 'companies' && !companyId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"><div><span className="eyebrow">INVENTARIOS POR EMPRESA</span><h2>Elegí una empresa</h2><p>Ingresá al inventario de una empresa para ver sus Áreas y continuar hacia Yacimientos, instalaciones y equipos.</p></div><span className="count-pill">{companies.length}</span></div>
{companies.length === 0 ? <EmptyState title="No hay empresas para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{companies.map((item) => <SummaryCard key={item.id} item={item} subtitle="Inventario de empresa" href={navigationHref(searchParams, 'companies', { companyId: item.id })} />)}</div>}
</div>;
}
if (section === 'companies' && companyId && !parentId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-current-heading"><div><span className="eyebrow">INVENTARIO DE EMPRESA</span><h2>{company?.name ?? 'Organización'}</h2><p>{company?.code} · Áreas con registros asociados a este inventario.</p></div>{company && <Link className="button secondary" to={`/inventarios/${company.id}`}>Ver ficha</Link>}</div>
<div className="asset-browser-group">
<div className="asset-browser-group-heading"><div><h3>Áreas del inventario</h3><p>Seleccioná un Área para continuar hacia Yacimientos, instalaciones y equipos.</p></div><span>{areas.length}</span></div>
{areas.length === 0 ? <EmptyState title="Sin Áreas en el inventario" text="No hay Áreas con registros asignados a esta empresa para los filtros actuales." /> : <div className="asset-browser-list">{areas.map((item) => <SummaryCard key={item.id} item={item} subtitle="Área" href={navigationHref(searchParams, 'companies', { companyId, parentId: item.id })} />)}</div>}
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
{hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda para acotar el resultado.</Alert>}
<div className="asset-browser-current-heading">
<div>
<span className="eyebrow">{current?.type.name ?? 'INVENTARIO'}</span>
<h2>{current?.name ?? 'Nivel de Inventario'}</h2>
<p>{current?.code ?? ''}</p>
</div>
</div>;
}
if (section === 'territory' && !parentId) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
<div className="asset-browser-section-heading"><div><span className="eyebrow">TERRITORIO</span><h2>Áreas y yacimientos</h2><p>Ingresá por un Área para navegar su estructura física.</p></div><span className="count-pill">{areas.length}</span></div>
{areas.length === 0 ? <EmptyState title="No hay Áreas para mostrar" text="Probá con otra búsqueda o revisá los filtros." /> : <div className="asset-browser-list">{areas.map((item) => <SummaryCard key={item.id} item={item} subtitle="Área" href={navigationHref(searchParams, 'territory', { parentId: item.id })} />)}</div>}
</div>;
}
if (parentId && current) {
return <div className="asset-browser-panel">
{breadcrumb}
{error && <Alert>{error}</Alert>}
{hasMore && <Alert type="info">Este nivel tiene más de 200 registros. Usá la búsqueda o los filtros para acotar los resultados.</Alert>}
<div className="asset-browser-current-heading">
<div><span className="eyebrow">{current.type.name}</span><h2>{current.name}</h2><p>{current.code}{current.commonName ? ` · ${current.commonName}` : ''}{company ? ` · Contexto: ${company.name}` : ''}</p></div>
<div className="asset-browser-current-actions"><Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha</Link>{canCreate && <Link className="button primary" to={`/inventarios/nuevo?parentId=${current.id}`}><Icon name="plus" />Agregar aquí</Link>}</div>
<div className="asset-browser-current-actions">
{current && <Link className="button secondary" to={`/inventarios/${current.id}`}>Ver ficha</Link>}
{canCreate && current?.type.code.toLowerCase() !== 'subinstalacion' && <Link className="button primary" to={`/inventarios/nuevo?parentId=${parentId}`}><Icon name="plus" />Agregar aquí</Link>}
</div>
{groupedChildren.length === 0 ? <EmptyState title="No hay niveles inferiores" text="Este nivel no tiene registros inferiores que coincidan con los filtros actuales." /> : <div className="asset-browser-groups">
{groupedChildren.map(({ key, items }) => <section className={`asset-browser-group group-${key}`} key={key}>
<div className="asset-browser-group-heading"><div><h3>{GROUP_LABELS[key].title}</h3><p>{GROUP_LABELS[key].description}</p></div><span>{items.length}</span></div>
<div className="asset-browser-list">{items.map((item) => <AssetCard key={item.id} item={item} href={navigationHref(searchParams, activeSection, { companyId: companyId || undefined, parentId: item.id })} />)}</div>
</section>)}
</div>}
</div>;
}
</div>
return <>{error && <Alert>{error}</Alert>}<EmptyState title="No se pudo abrir la estructura" text="Volvé al inicio de Inventarios e intentá nuevamente." /></>;
<section className="asset-browser-group">
<div className="asset-browser-group-heading">
<div><h3>{nextLevelLabel(current?.type.code)}</h3><p>La jerarquía permitida es Área Yacimiento Instalación Subinstalación.</p></div>
<span>{children.length}</span>
</div>
{children.length === 0
? <EmptyState
title={current?.type.code.toLowerCase() === 'subinstalacion' ? 'Fin de la jerarquía' : 'No hay registros en este nivel'}
text={current?.type.code.toLowerCase() === 'yacimiento'
? 'Todavía no hay Instalaciones reales registradas en este Yacimiento.'
: current?.type.code.toLowerCase() === 'instalacion'
? 'Todavía no hay Subinstalaciones registradas en esta Instalación.'
: 'No hay registros que coincidan con la búsqueda actual.'}
/>
: <div className="asset-browser-list">{children.map((item) => <InventoryCard key={item.id} item={item} href={navigationHref(searchParams,item.id)} />)}</div>}
</section>
</div>;
}
@@ -2,54 +2,66 @@ import { SearchableSelect } from '../../components/SearchableSelect';
import { useEffect, useMemo, useState } from 'react';
import { Alert, LoadingBlock, errorMessage } from '../../components/Feedback';
import { Icon } from '../../components/Icon';
import { getFindingCatalogAdmin } from '../../lib/api';
import type { FindingAdminCatalog } from '../../lib/api';
import {
getFindingCatalogAssetTypeSelection,
listAssetTypes,
replaceFindingCatalogAssetTypeSelection,
} from '../../lib/api';
import type { AssetType, FindingCatalogAssetTypeSelection } from '../../lib/api';
listInventoryFamiliesAdmin,
replaceInventoryFamilyFindings,
} from '../../lib/inventoryStructureApi';
import type { InventoryFamily } from '../../lib/inventoryStructureApi';
const EMPTY_CATALOG: FindingAdminCatalog = { categories: [], items: [] };
export function FindingCatalogTypeApplicabilityPanel() {
const [types, setTypes] = useState<AssetType[]>([]);
const [typeId, setTypeId] = useState('');
const [selection, setSelection] = useState<FindingCatalogAssetTypeSelection | null>(null);
const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [catalog, setCatalog] = useState<FindingAdminCatalog>(EMPTY_CATALOG);
const [familyId, setFamilyId] = useState('');
const [enabled, setEnabled] = useState<Set<string>>(new Set());
const [reason, setReason] = useState('');
const [reason, setReason] = useState('Actualización de aplicabilidad por clasificación de Inventario');
const [search, setSearch] = useState('');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const selectedFamily = families.find((family) => family.id === familyId) ?? null;
useEffect(() => {
listAssetTypes()
.then((loaded) => {
const technical = loaded.filter((type) => type.isActive && type.operationalRole === 'GENERIC');
setTypes(technical);
setTypeId(technical[0]?.id ?? '');
Promise.all([listInventoryFamiliesAdmin(), getFindingCatalogAdmin()])
.then(([loadedFamilies, loadedCatalog]) => {
const activeFamilies = loadedFamilies.filter((family) => family.isActive !== false);
setFamilies(activeFamilies);
setCatalog(loadedCatalog);
setFamilyId(activeFamilies[0]?.id ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (!typeId) { setSelection(null); return; }
setLoading(true); setError(''); setSuccess('');
getFindingCatalogAssetTypeSelection(typeId)
.then((loaded) => {
setSelection(loaded);
setEnabled(new Set(loaded.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(loaded.reason ?? '');
})
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [typeId]);
const selected = families.find((family) => family.id === familyId);
setEnabled(new Set(selected?.findingItemIds ?? []));
setSuccess('');
setError('');
}, [familyId, families]);
const activeCategoryIds = useMemo(() => new Set(
catalog.categories.filter((category) => category.isActive).map((category) => category.id),
), [catalog.categories]);
const categoryName = useMemo(() => new Map(
catalog.categories.map((category) => [category.id, category.name]),
), [catalog.categories]);
const visible = useMemo(() => {
const needle = search.trim().toLocaleLowerCase();
return selection?.items.filter((item) => !needle || [item.title, item.code, item.categoryName]
.some((value) => value.toLocaleLowerCase().includes(needle))) ?? [];
}, [selection, search]);
const needle = search.trim().toLocaleLowerCase('es-AR');
return catalog.items.filter((item) =>
item.isActive
&& activeCategoryIds.has(item.categoryId)
&& (!needle || [item.title, item.code, categoryName.get(item.categoryId) ?? '']
.some((value) => value.toLocaleLowerCase('es-AR').includes(needle))),
);
}, [catalog.items, search, activeCategoryIds, categoryName]);
const toggle = (id: string) => setEnabled((current) => {
const next = new Set(current);
@@ -58,17 +70,19 @@ export function FindingCatalogTypeApplicabilityPanel() {
});
const save = async () => {
if (!selection) return;
if (!selectedFamily) return;
setSaving(true); setError(''); setSuccess('');
try {
const saved = await replaceFindingCatalogAssetTypeSelection(selection.assetType.id, {
enabledItemIds: [...enabled],
const saved = await replaceInventoryFamilyFindings(selectedFamily.id, {
itemIds: [...enabled],
reason,
});
setSelection(saved);
setEnabled(new Set(saved.items.filter((item) => item.enabled).map((item) => item.id)));
setReason(saved.reason ?? reason);
setSuccess(`Aplicabilidad guardada para ${saved.assetType.name}.`);
const itemIds = saved.items.map((item) => item.id);
setEnabled(new Set(itemIds));
setFamilies((current) => current.map((family) => family.id === selectedFamily.id
? { ...family, findingItemIds: itemIds, findingCount: itemIds.length }
: family));
setSuccess(`Hallazgos guardados para ${selectedFamily.name}.`);
} catch (requestError) {
setError(errorMessage(requestError));
} finally {
@@ -76,20 +90,43 @@ export function FindingCatalogTypeApplicabilityPanel() {
}
};
if (loading && types.length === 0) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (types.length === 0) return null;
if (loading) return <div className="panel"><LoadingBlock label="Cargando aplicabilidad…" /></div>;
if (families.length === 0) return <Alert>No hay clasificaciones de Instalación/Subinstalación disponibles. Crealas primero en Configuración de Inventarios.</Alert>;
return <section className="panel finding-applicability-panel">
<div className="panel-heading"><div><span className="eyebrow">APLICABILIDAD POR TIPO TÉCNICO</span><h2>Qué hallazgos verá el inspector</h2><p className="section-copy">Configurá el catálogo base para cada tipo de elemento del Inventario. Después se pueden hacer excepciones por objeto concreto.</p></div><span className="count-pill">{enabled.size} habilitados</span></div>
<div className="panel-heading">
<div>
<span className="eyebrow">APLICABILIDAD POR INSTALACIÓN / SUBINSTALACIÓN</span>
<h2>Qué Hallazgos verá el inspector</h2>
<p className="section-copy">Los Hallazgos se vinculan a la clasificación concreta del elemento, no a una función. La opción OTROS permanece siempre disponible en la APK.</p>
</div>
<span className="count-pill">{enabled.size} vinculados</span>
</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="form-grid finding-applicability-toolbar">
<label className="field"><span>Tipo técnico</span><SearchableSelect value={typeId} onChange={(event) => setTypeId(event.target.value)}>{types.map((type) => <option value={type.id} key={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field"><span>Buscar hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
<label className="field">
<span>Clasificación de Inventario</span>
<SearchableSelect value={familyId} onChange={(event) => setFamilyId(event.target.value)}>
{families.map((family) => <option value={family.id} key={family.id}>
{family.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'} · {family.parentFamilyName ? `${family.parentFamilyName}` : ''}{family.name}
</option>)}
</SearchableSelect>
</label>
<label className="field"><span>Buscar Hallazgo</span><input value={search} onChange={(event) => setSearch(event.target.value)} placeholder="Título, código o categoría…" /></label>
</div>
{selection && !selection.configured && <div className="temporal-notice"><Icon name="alert" /><p><strong>Este tipo todavía no fue configurado.</strong> Para no romper el funcionamiento actual, hoy recibe todo el catálogo activo. Al guardar esta pantalla, sólo quedarán habilitados los seleccionados.</p></div>}
<div className="catalog-selection-actions"><button type="button" className="button secondary" onClick={() => setEnabled(new Set(selection?.items.map((item) => item.id) ?? []))}>Seleccionar todos</button><button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button></div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}><input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} /><span><strong>{item.title}</strong><small>{item.categoryName} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span></label>)}</div>
<label className="field"><span>Motivo de configuración</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: catálogo aplicable a tanques según criterio técnico de Hidrocarburos…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar aplicabilidad'}</button></div>
{selectedFamily && <div className="temporal-notice">
<Icon name="layers" />
<p><strong>{selectedFamily.level === 'INSTALLATION' ? 'Instalación' : 'Subinstalación'}:</strong> {selectedFamily.parentFamilyName ? `${selectedFamily.parentFamilyName}` : ''}{selectedFamily.name}. Actualmente tiene {selectedFamily.findingCount ?? enabled.size} Hallazgo{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'} asociado{(selectedFamily.findingCount ?? enabled.size) === 1 ? '' : 's'}.</p>
</div>}
<div className="catalog-selection-actions">
<button type="button" className="button secondary" onClick={() => setEnabled(new Set(visible.map((item) => item.id)))}>Seleccionar visibles</button>
<button type="button" className="button secondary" onClick={() => setEnabled(new Set())}>Quitar todos</button>
</div>
<div className="finding-selection-list">{visible.map((item) => <label className="finding-selection-row" key={item.id}>
<input type="checkbox" checked={enabled.has(item.id)} onChange={() => toggle(item.id)} />
<span><strong>{item.title}</strong><small>{categoryName.get(item.categoryId) ?? 'Catálogo'} · {item.code}{item.suggestedSeverity ? ` · gravedad sugerida ${item.suggestedSeverity}/10` : ''}</small></span>
</label>)}</div>
<label className="field"><span>Motivo del cambio</span><textarea rows={2} value={reason} onChange={(event) => setReason(event.target.value)} maxLength={2000} placeholder="Ej.: Hallazgos aplicables a esta Subinstalación según criterio técnico…" /></label>
<div className="form-actions"><button type="button" className="button primary" disabled={saving || reason.trim().length < 5 || !selectedFamily} onClick={save}><Icon name="check" />{saving ? 'Guardando…' : 'Guardar Hallazgos vinculados'}</button></div>
</section>;
}
-2
View File
@@ -32,14 +32,12 @@ const documents: NavItem[] = [
const master: NavItem[] = [
{ to: '/inventarios', label: 'Inventarios', icon: 'layers', permission: 'assets.read' },
{ to: '/mapa', label: 'Mapa', icon: 'map', permission: 'assets.read' },
{ to: '/importaciones', label: 'Importaciones', icon: 'upload', permission: 'asset_imports.read' },
];
const administration: NavItem[] = [
{ to: '/admin/users', label: 'Usuarios', icon: 'users', permission: 'users.read' },
{ to: '/admin/roles', label: 'Roles y permisos', icon: 'shield', permission: 'roles.read' },
{ to: '/admin/asset-types', label: 'Configuración de Inventarios', icon: 'layers', permission: 'asset_types.read' },
{ to: '/admin/inventory-functions', label: 'Catálogo de funciones', icon: 'history', permission: 'asset_types.manage' },
{ to: '/admin/finding-catalog', label: 'Catálogo de hallazgos', icon: 'alert', permission: 'finding_catalog.manage' },
{ to: '/admin/document-delivery', label: 'Entrega documental', icon: 'audit', permission: 'document_delivery.read' },
];
+2 -1
View File
@@ -10,6 +10,7 @@ export interface DashboardSummaryF4 {
inactiveUsers: number;
activeSessions: number;
openFindings: number;
actsInFollowUp: number;
findingsWithoutControlDate: number;
overdueControls: number;
controlsNext30Days: number;
@@ -17,7 +18,7 @@ export interface DashboardSummaryF4 {
reportsOfficialized: number;
sealedActsWithoutReport: number;
};
recentAudit: Array<{
recentInspectorActivity: Array<{
id: string;
occurredAt: string;
actorUsername: string | null;
+106
View File
@@ -0,0 +1,106 @@
import { apiRequest } from './api';
import type {
AssetDataOrigin,
AssetInformationStatus,
AssetOperationalStatus,
PageMeta,
} from './api';
export interface InventoryBrowserArea {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
informationStatus: AssetInformationStatus;
operationalStatus: AssetOperationalStatus;
currentOperator: { id: string; code: string; name: string } | null;
yacimientoCount: number;
inventoryCount: number;
}
export interface InventoryBrowserItem {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
parent: { id: string; code: string; name: string } | null;
informationStatus: AssetInformationStatus;
operationalStatus: AssetOperationalStatus;
isInventoryInstance: boolean;
inventoryFamily: { id: string; code: string; name: string; level: string } | null;
childrenCount: number;
hasGeometry: boolean;
updatedAt: string;
}
export interface InventoryListItem {
id: string;
code: string;
name: string;
commonName: string | null;
type: { id: string; code: string; name: string };
parent: { id: string; code: string; name: string } | null;
operationalArea: { id: string; code: string; name: string } | null;
operatorCompany: { id: string; code: string; name: string } | null;
informationStatus: AssetInformationStatus;
operationalStatus: AssetOperationalStatus;
childrenCount: number;
hasGeometry: boolean;
geometryType: string | null;
mediaCount: number;
dataOrigin: AssetDataOrigin;
provenanceVerified: boolean;
currentVersion: number;
updatedAt: string;
}
export interface InventoryBrowserParent {
id: string;
code: string;
name: string;
typeCode: string;
}
export interface InventoryQuery {
page?: number;
pageSize?: number;
search?: string;
typeId?: string;
status?: AssetInformationStatus | '';
operationalStatus?: AssetOperationalStatus | '';
operationalAreaId?: string;
operatorCompanyId?: string;
needsValidation?: boolean;
hasGeometry?: boolean;
}
function queryString(params: InventoryQuery): string {
const query = new URLSearchParams();
Object.entries(params).forEach(([key,value]) => {
if (value === undefined || value === null || value === '') return;
query.set(key,String(value));
});
return query.size ? `?${query}` : '';
}
export function listRealInventory(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryListItem[]; meta: PageMeta }>(
`/inventory-browser/items${queryString(params)}`,
);
}
export function listInventoryAreas(params: InventoryQuery = {}) {
return apiRequest<{ data: InventoryBrowserArea[]; meta: { count: number } }>(
`/inventory-browser/areas${queryString(params)}`,
);
}
export function listInventoryChildren(parentId: string, params: InventoryQuery = {}) {
return apiRequest<{
parent: InventoryBrowserParent;
data: InventoryBrowserItem[];
meta: { count: number; hasMore: boolean };
}>(`/inventory-browser/${parentId}/children${queryString(params)}`);
}
+44 -2
View File
@@ -1,17 +1,22 @@
import { apiRequest } from './api';
export type InventoryStructureKind = 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
export type InventoryStructureKind = 'EMPRESA' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
export interface InventoryFamily {
id: string;
code: string;
name: string;
level: 'INSTALLATION' | 'SUBINSTALLATION';
legacyTypeCode: string | null;
legacyTypeCode?: string | null;
informationLabels: string[];
sourceReference?: string | null;
isActive?: boolean;
parentFamilyId: string | null;
parentFamilyCode: string | null;
parentFamilyName: string | null;
assetCount?: number;
findingCount?: number;
findingItemIds?: string[];
}
export interface InventoryStructureLevel {
@@ -23,6 +28,7 @@ export interface InventoryStructureLevel {
}
export interface InventoryStructureOptions {
independentMasters: InventoryStructureLevel[];
levels: InventoryStructureLevel[];
installationFamilies: InventoryFamily[];
subinstallationFamilies: InventoryFamily[];
@@ -79,6 +85,7 @@ export interface CreatedInventoryStructure {
operationalStatus: string;
type: { id: string; code: string; name: string };
parent: null | { id: string; code: string; name: string };
operationalArea?: null | { id: string; code: string; name: string };
inventoryFamily: null | {
id: string;
code: string;
@@ -105,6 +112,41 @@ export function getInventoryFamilyFindings(familyId: string) {
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`);
}
export async function listInventoryFamiliesAdmin() {
return (await apiRequest<{ data: InventoryFamily[] }>('/inventory-families/admin')).data;
}
export function createInventoryFamily(input: {
level: 'INSTALLATION' | 'SUBINSTALLATION';
name: string;
parentFamilyId?: string | null;
informationLabels?: string[];
}) {
return apiRequest<InventoryFamily>('/inventory-families', {
method: 'POST', body: JSON.stringify(input),
});
}
export function updateInventoryFamily(familyId: string, input: {
name?: string;
parentFamilyId?: string | null;
informationLabels?: string[];
isActive?: boolean;
}) {
return apiRequest<InventoryFamily>(`/inventory-families/${familyId}`, {
method: 'PATCH', body: JSON.stringify(input),
});
}
export function replaceInventoryFamilyFindings(familyId: string, input: {
itemIds: string[];
reason: string;
}) {
return apiRequest<InventoryFamilyFindings>(`/inventory-families/${familyId}/findings`, {
method: 'PUT', body: JSON.stringify(input),
});
}
export function createInventoryStructure(input: {
kind: InventoryStructureKind;
code?: string | null;
+1
View File
@@ -3,6 +3,7 @@ import ReactDOM from 'react-dom/client';
import { BrowserRouter } from 'react-router';
import 'maplibre-gl/dist/maplibre-gl.css';
import './styles.css';
import './f5.css';
import { App } from './app/App';
import { AuthProvider } from './auth/AuthContext';
+157 -152
View File
@@ -1,32 +1,26 @@
import { SearchableSelect } from '../components/SearchableSelect';
import { useEffect, useState } from 'react';
import { useEffect, useMemo, useState } from 'react';
import type { FormEvent } 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 {
bootstrapMasterDefaults,
createAssetAttribute,
createAssetType,
enrichMasterDefaults,
getMasterEnrichmentStatus,
listAssetTypes,
updateAssetAttribute,
updateAssetType,
} from '../lib/api';
import type {
AssetAttributeDataType,
AssetAttributeDefinition,
AssetType,
AssetTypeOperationalRole,
MasterEnrichmentStatus,
} from '../lib/api';
const OPERATIONAL_ROLES: Array<{ value: AssetTypeOperationalRole; label: string; help: string }> = [
{ value: 'GENERIC', label: 'Elemento operativo / genérico', help: 'Instalaciones, estaciones, equipos y demás elementos administrables.' },
{ value: 'AREA', label: 'Área', help: 'Representa el ámbito territorial de operación.' },
{ value: 'COMPANY', label: 'Organización', help: 'Empresa, UTE u otra organización vinculable a áreas.' },
];
import {
createInventoryFamily,
listInventoryFamiliesAdmin,
updateInventoryFamily,
} from '../lib/inventoryStructureApi';
import type { InventoryFamily } from '../lib/inventoryStructureApi';
const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> = [
{ value: 'TEXT', label: 'Texto' },
@@ -37,27 +31,50 @@ const ATTRIBUTE_TYPES: Array<{ value: AssetAttributeDataType; label: string }> =
{ value: 'SELECT', label: 'Lista de opciones' },
];
type CanonicalKind = 'EMPRESA' | 'AREA' | 'YACIMIENTO' | 'INSTALACION' | 'SUBINSTALACION';
type FamilyEditor = InventoryFamily | 'new' | null;
const LEVELS: Array<{ kind: CanonicalKind; label: string; description: string }> = [
{ kind: 'EMPRESA', label: 'Empresa', description: 'Maestro independiente. Se vincula temporalmente a un Área.' },
{ kind: 'AREA', label: 'Área', description: 'Raíz territorial de la estructura física.' },
{ kind: 'YACIMIENTO', label: 'Yacimiento', description: 'Pertenece a un Área; su nombre puede repetirse en otra Área.' },
{ kind: 'INSTALACION', label: 'Instalación', description: 'Instancia física dentro de un Yacimiento y con clasificación técnica.' },
{ kind: 'SUBINSTALACION', label: 'Subinstalación', description: 'Instancia física dentro de una Instalación y con clasificación técnica.' },
];
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;
const code = kind.toLowerCase();
return types.find((type) => type.code.toLowerCase() === code && type.isActive) ?? null;
}
function attributeTypeLabel(value: AssetAttributeDataType) {
return ATTRIBUTE_TYPES.find((item) => item.value === value)?.label ?? value;
}
function typeCodeFromName(value: string) {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
function attributeCodeFromName(value: string) {
return value.normalize('NFD').replace(/[\u0300-\u036f]/g, '').toLowerCase().trim()
.replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '').slice(0, 80);
}
export function AssetTypesPage() {
const { hasPermission } = useAuth();
const canManage = hasPermission('asset_types.manage');
const [types, setTypes] = useState<AssetType[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [creating, setCreating] = useState(false);
const [code, setCode] = useState('');
const [name, setName] = useState('');
const [description, setDescription] = useState('');
const [canBeRoot, setCanBeRoot] = useState(false);
const [isActive, setIsActive] = useState(true);
const [operationalRole, setOperationalRole] = useState<AssetTypeOperationalRole>('GENERIC');
const [parentTypeIds, setParentTypeIds] = useState<string[]>([]);
const [families, setFamilies] = useState<InventoryFamily[]>([]);
const [selectedKind, setSelectedKind] = useState<CanonicalKind>('INSTALACION');
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [familyEditor, setFamilyEditor] = useState<FamilyEditor>(null);
const [familyLevel, setFamilyLevel] = useState<'INSTALLATION' | 'SUBINSTALLATION'>('INSTALLATION');
const [familyName, setFamilyName] = useState('');
const [familyParentId, setFamilyParentId] = useState('');
const [familyActive, setFamilyActive] = useState(true);
const [attributeEditor, setAttributeEditor] = useState<AssetAttributeDefinition | 'new' | null>(null);
const [attributeCode, setAttributeCode] = useState('');
const [attributeName, setAttributeName] = useState('');
@@ -67,166 +84,154 @@ export function AssetTypesPage() {
const [attributeUnit, setAttributeUnit] = useState('');
const [attributeOptions, setAttributeOptions] = useState('');
const [attributeOrder, setAttributeOrder] = useState(0);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [enrichment, setEnrichment] = useState<MasterEnrichmentStatus | null>(null);
const selected = types.find((type) => type.id === selectedId) ?? null;
const selectType = (type: AssetType) => {
setCreating(false); setSelectedId(type.id); setCode(type.code); setName(type.name);
setDescription(type.description); setCanBeRoot(type.canBeRoot); setIsActive(type.isActive);
setOperationalRole(type.operationalRole);
setParentTypeIds(type.allowedParentTypes.map((parent) => parent.id));
setAttributeEditor(null); setError(''); setSuccess('');
};
const startCreate = () => {
setCreating(true); setSelectedId(null); setCode(''); setName(''); setDescription('');
setCanBeRoot(false); setIsActive(true); setOperationalRole('GENERIC'); setParentTypeIds([]);
setAttributeEditor(null); setError(''); setSuccess('');
};
const load = async (preferId?: string) => {
const loaded = await listAssetTypes();
setTypes(loaded);
if (loaded.length > 0) {
try { setEnrichment(await getMasterEnrichmentStatus()); } catch { setEnrichment(null); }
} else {
setEnrichment(null);
}
const next = loaded.find((type) => type.id === preferId) ?? loaded[0];
if (next) selectType(next);
const load = async () => {
const [loadedTypes, loadedFamilies] = await Promise.all([
listAssetTypes(),
listInventoryFamiliesAdmin(),
]);
setTypes(loadedTypes);
setFamilies(loadedFamilies);
};
useEffect(() => {
load().catch((requestError) => setError(errorMessage(requestError))).finally(() => setLoading(false));
}, []);
const toggleParent = (id: string) => setParentTypeIds((current) =>
current.includes(id) ? current.filter((value) => value !== id) : [...current, id],
);
const selectedType = canonicalType(types, selectedKind);
const installationFamilies = useMemo(() => families.filter((item) => item.level === 'INSTALLATION'), [families]);
const subinstallationFamilies = useMemo(() => families.filter((item) => item.level === 'SUBINSTALLATION'), [families]);
const installDefaultMaster = async () => {
const confirmed = window.confirm(
'¿Instalar la configuración inicial de Hidrocarburos?\n\nSe crearán tipos, jerarquías y atributos base. No se crearán empresas, áreas ni registros reales.',
);
if (!confirmed) return;
setSaving(true); setError(''); setSuccess('');
try {
const result = await bootstrapMasterDefaults();
setTypes(result.data);
const next = result.data.find((type) => type.code === 'area') ?? result.data[0];
if (next) selectType(next);
setSuccess(`Configuración inicial instalada: ${result.createdTypeCount} tipos, ${result.createdAttributeCount} atributos y ${result.createdParentRuleCount} reglas de jerarquía.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
const openNewFamily = (level: 'INSTALLATION' | 'SUBINSTALLATION') => {
setFamilyEditor('new'); setFamilyLevel(level); setFamilyName(''); setFamilyParentId(''); setFamilyActive(true);
setError(''); setSuccess('');
};
const openFamily = (family: InventoryFamily) => {
setFamilyEditor(family); setFamilyLevel(family.level); setFamilyName(family.name);
setFamilyParentId(family.parentFamilyId ?? ''); setFamilyActive(family.isActive !== false);
setError(''); setSuccess('');
};
const enrichTechnicalCatalog = async () => {
const confirmed = window.confirm(
'¿Completar el catálogo técnico de Hidrocarburos?\n\nSólo se agregarán tipos, atributos y relaciones de jerarquía que falten. No se modificarán tipos existentes ni se crearán registros reales.',
);
if (!confirmed) return;
setSaving(true); setError(''); setSuccess('');
try {
const result = await enrichMasterDefaults();
setTypes(result.data);
setEnrichment(await getMasterEnrichmentStatus());
const next = result.data.find((type) => type.id === selectedId) ?? result.data.find((type) => type.code === 'area') ?? result.data[0];
if (next) selectType(next);
setSuccess(`Catálogo técnico completado: ${result.createdTypeCount} tipos, ${result.createdAttributeCount} atributos y ${result.createdParentRuleCount} reglas nuevas.`);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const saveType = async (event: FormEvent) => {
const saveFamily = async (event: FormEvent) => {
event.preventDefault(); setSaving(true); setError(''); setSuccess('');
try {
const saved = creating
? await createAssetType({ code, name, description, canBeRoot, operationalRole, allowedParentTypeIds: parentTypeIds })
: await updateAssetType(selected!.id, { name, description, canBeRoot, isActive, operationalRole, allowedParentTypeIds: parentTypeIds });
await load(saved.id);
setSuccess(creating ? 'Tipo de elemento creado correctamente' : 'Tipo de elemento actualizado');
setCreating(false);
if (familyEditor === 'new') {
await createInventoryFamily({
level: familyLevel,
name: familyName.trim(),
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
});
setSuccess(`${familyLevel === 'INSTALLATION' ? 'Tipo de Instalación' : 'Tipo de Subinstalación'} creado.`);
} else if (familyEditor) {
await updateInventoryFamily(familyEditor.id, {
name: familyName.trim(),
parentFamilyId: familyLevel === 'SUBINSTALLATION' ? familyParentId : null,
isActive: familyActive,
});
setSuccess('Clasificación actualizada.');
}
await load();
setFamilyEditor(null);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
const openAttribute = (attribute: AssetAttributeDefinition | 'new') => {
setAttributeEditor(attribute);
setAttributeEditor(attribute); setError(''); setSuccess('');
if (attribute === 'new') {
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT');
setAttributeRequired(false); setAttributeActive(true); setAttributeUnit('');
setAttributeOptions(''); setAttributeOrder(selected?.attributes.length ?? 0);
setAttributeCode(''); setAttributeName(''); setAttributeType('TEXT'); setAttributeRequired(false);
setAttributeActive(true); setAttributeUnit(''); setAttributeOptions('');
setAttributeOrder(selectedType?.attributes.length ?? 0);
} else {
setAttributeCode(attribute.code); setAttributeName(attribute.name);
setAttributeType(attribute.dataType); setAttributeRequired(attribute.isRequired);
setAttributeActive(attribute.isActive); setAttributeUnit(attribute.unit ?? '');
setAttributeOptions(attribute.options?.join('\n') ?? ''); setAttributeOrder(attribute.sortOrder);
setAttributeCode(attribute.code); setAttributeName(attribute.name); setAttributeType(attribute.dataType);
setAttributeRequired(attribute.isRequired); setAttributeActive(attribute.isActive);
setAttributeUnit(attribute.unit ?? ''); setAttributeOptions(attribute.options?.join('\n') ?? '');
setAttributeOrder(attribute.sortOrder);
}
setError(''); setSuccess('');
};
const saveAttribute = async (event: FormEvent) => {
event.preventDefault();
if (!selected || !attributeEditor) return;
if (!selectedType || !attributeEditor) return;
setSaving(true); setError(''); setSuccess('');
const options = attributeOptions.split(/\n|,/).map((item) => item.trim()).filter(Boolean);
try {
const saved = attributeEditor === 'new'
? await createAssetAttribute(selected.id, {
code: attributeCode, name: attributeName, dataType: attributeType,
isRequired: attributeRequired, unit: attributeUnit || null,
...(attributeType === 'SELECT' ? { options } : {}), sortOrder: attributeOrder,
})
: await updateAssetAttribute(selected.id, attributeEditor.id, {
name: attributeName, dataType: attributeType,
isRequired: attributeRequired, isActive: attributeActive,
unit: attributeUnit || null,
options: attributeType === 'SELECT' ? options : null,
sortOrder: attributeOrder,
});
await load(saved.id);
setSuccess(attributeEditor === 'new' ? 'Atributo agregado correctamente' : 'Atributo actualizado');
if (attributeEditor === 'new') {
await createAssetAttribute(selectedType.id, {
code: attributeCode,
name: attributeName,
dataType: attributeType,
isRequired: attributeRequired,
unit: attributeUnit || null,
...(attributeType === 'SELECT' ? { options } : {}),
sortOrder: attributeOrder,
});
setSuccess(`Nueva columna agregada a ${LEVELS.find((item) => item.kind === selectedKind)?.label}.`);
} else {
await updateAssetAttribute(selectedType.id, attributeEditor.id, {
name: attributeName,
dataType: attributeType,
isRequired: attributeRequired,
isActive: attributeActive,
unit: attributeUnit || null,
options: attributeType === 'SELECT' ? options : null,
sortOrder: attributeOrder,
});
setSuccess('Columna actualizada.');
}
await load();
setAttributeEditor(null);
} catch (requestError) { setError(errorMessage(requestError)); }
finally { setSaving(false); }
};
if (loading) return <LoadingBlock label="Cargando tipos de inventario…" />;
if (loading) return <LoadingBlock label="Cargando configuración de Inventarios…" />;
return <section>
<div className="page-heading"><div><span className="eyebrow">ADMINISTRACIÓN</span><h1>Configuración de Inventarios</h1><p>Definí qué clases de elementos pueden formar parte de los inventarios y qué información necesita cada una. Las reglas técnicas quedan en configuración avanzada.</p></div>{canManage && <button className="button primary" onClick={startCreate}><Icon name="plus" />Nuevo tipo</button>}</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
{types.length > 0 && enrichment && !enrichment.complete && <div className="panel master-bootstrap-panel"><div className="master-bootstrap-copy"><span className="eyebrow">CATÁLOGO TÉCNICO</span><h2>Completar nomenclatura de inspección</h2><p>La estructura base ya existe. Esta mejora agrega únicamente las familias técnicas que faltan: plantas, baterías, sistemas y equipos específicos, manteniendo un solo tipo Pozo con método/función configurable.</p><div className="master-bootstrap-notice"><strong>Es una ampliación no destructiva.</strong><span>No reemplaza configuraciones existentes ni crea operadoras, áreas o registros reales.</span></div></div><div className="master-bootstrap-types"><strong>Pendiente</strong><div className="bootstrap-type-grid"><span><Icon name="check" />{enrichment.missingTypeCodes.length} tipos técnicos</span><span><Icon name="check" />{enrichment.missingAttributeCount} atributos</span><span><Icon name="check" />{enrichment.missingParentRuleCount} reglas de jerarquía</span></div></div>{canManage && enrichment.canApply ? <div className="master-bootstrap-actions"><button className="button primary" onClick={enrichTechnicalCatalog} disabled={saving}><Icon name="check" />{saving ? 'Completando…' : 'Completar catálogo técnico'}</button></div> : <Alert>{enrichment.reason ?? 'No se puede aplicar automáticamente sobre esta configuración.'}</Alert>}</div>}
{types.length === 0 && !creating ? <div className="panel master-bootstrap-panel"><div className="master-bootstrap-copy"><span className="eyebrow">CONFIGURACIÓN INICIAL</span><h2>Preparar inventarios de Hidrocarburos</h2><p>La configuración de inventarios está vacía. Podés instalar una estructura inicial segura con niveles territoriales, instalaciones, sistemas y familias técnicas de inspección.</p><div className="master-bootstrap-notice"><strong>No carga datos reales automáticamente.</strong><span>Las operadoras, áreas y registros concretos se cargarán después con fuente y vigencia.</span></div></div><div className="master-bootstrap-types"><strong>Incluye</strong><div className="bootstrap-type-grid">{['Área','Organización','Yacimiento / Locación','Planta / Batería / Estación','Sistemas técnicos','Pozo con método configurable','Tanques, bombas y otros equipos','Ducto / Cañería'].map((label) => <span key={label}><Icon name="check" />{label}</span>)}</div></div>{canManage ? <div className="master-bootstrap-actions"><button className="button primary" onClick={installDefaultMaster} disabled={saving}><Icon name="check" />{saving ? 'Instalando…' : 'Instalar configuración base'}</button><button className="button secondary" onClick={startCreate} disabled={saving}><Icon name="plus" />Configurar manualmente</button></div> : <Alert>Necesitás permiso para administrar tipos de inventario y ejecutar la configuración inicial.</Alert>}</div> : <div className="asset-types-layout">
<aside className="panel role-list"><div className="role-list-heading"><strong>Tipos disponibles</strong><span>{types.length}</span></div>{types.map((type) => <button key={type.id} className={`role-list-item ${selectedId === type.id && !creating ? 'active' : ''}`} onClick={() => selectType(type)}><span><strong>{type.name}</strong><small>{type.code} · {OPERATIONAL_ROLES.find((role) => role.value === type.operationalRole)?.label ?? type.operationalRole}</small></span><span className="role-count">{type.assetCount} registro{Number(type.assetCount) === 1 ? '' : 's'}</span></button>)}</aside>
<div className="asset-type-workspace">
<form className="panel form-panel" onSubmit={saveType}>
<div className="panel-heading"><div><span className="eyebrow">{creating ? 'NUEVO TIPO' : selected?.isActive ? 'TIPO DISPONIBLE' : 'TIPO NO DISPONIBLE'}</span><h2>{creating ? 'Crear tipo de elemento' : name}</h2></div>{!creating && <span className={`status-badge ${isActive ? 'active' : 'inactive'}`}>{isActive ? 'Disponible' : 'No disponible'}</span>}</div>
<label className="field"><span>Nombre visible</span><input value={name} onChange={(event) => { setName(event.target.value); if (creating) setCode(typeCodeFromName(event.target.value)); }} disabled={!canManage} required maxLength={160} placeholder="Tanque, Bomba, Planta…" /></label>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} disabled={!canManage} maxLength={2000} rows={3} placeholder="Cuándo debe utilizarse este tipo de elemento" /></label>
<div className="type-summary-grid"><div><small>Comportamiento</small><strong>{OPERATIONAL_ROLES.find((role) => role.value === operationalRole)?.label}</strong></div><div><small>Puede estar dentro de</small><strong>{parentTypeIds.length ? types.filter((type) => parentTypeIds.includes(type.id)).map((type) => type.name).slice(0,3).join(', ') + (parentTypeIds.length > 3 ? ` +${parentTypeIds.length-3}` : '') : canBeRoot ? 'Es raíz' : 'Sin configurar'}</strong></div><div><small>Campos técnicos</small><strong>{selected?.attributes.length ?? 0}</strong></div></div>
<details className="advanced-config" open={creating}>
<summary>Configuración avanzada</summary>
<p>Estas opciones controlan reglas internas de los inventarios. La configuración inicial ya las deja preparadas para los tipos estándar.</p>
<div className="form-grid"><label className="field"><span>Código interno</span><input value={code} onChange={(event) => setCode(event.target.value.toLowerCase())} disabled={!creating || !canManage} required minLength={2} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Comportamiento</span><SearchableSelect value={operationalRole} onChange={(event) => setOperationalRole(event.target.value as AssetTypeOperationalRole)} disabled={!canManage}>{OPERATIONAL_ROLES.map((role) => <option key={role.value} value={role.value}>{role.label}</option>)}</SearchableSelect><small>{OPERATIONAL_ROLES.find((role) => role.value === operationalRole)?.help}</small></label></div>
<div className="type-flags"><label className="check-row"><input type="checkbox" checked={canBeRoot} onChange={(event) => setCanBeRoot(event.target.checked)} disabled={!canManage} /><span><strong>Puede ser raíz</strong><small>Permite crear registros de este tipo sin un registro padre.</small></span></label>{!creating && <label className="check-row"><input type="checkbox" checked={isActive} onChange={(event) => setIsActive(event.target.checked)} disabled={!canManage} /><span><strong>Tipo disponible</strong><small>Los tipos inactivos se conservan para el historial pero no aparecen en altas nuevas.</small></span></label>}</div>
<div className="parent-type-section"><h3>¿Dónde puede estar contenido?</h3><p>Seleccioná sólo los tipos que pueden actuar como padre físico.</p><div className="choice-grid">{types.filter((type) => type.id !== selected?.id).map((type) => <label className={`choice-card compact ${parentTypeIds.includes(type.id) ? 'selected' : ''}`} key={type.id}><input type="checkbox" checked={parentTypeIds.includes(type.id)} onChange={() => toggleParent(type.id)} disabled={!canManage} /><span><strong>{type.name}</strong></span><Icon name="check" /></label>)}</div></div>
</details>
{canManage && <div className="form-actions">{creating && <button className="button secondary" type="button" onClick={() => types[0] && selectType(types[0])}>Cancelar</button>}<button className="button primary" disabled={saving}>{saving ? 'Guardando…' : creating ? 'Crear tipo' : 'Guardar configuración'}</button></div>}
</form>
{!creating && selected && <div className="panel attributes-panel"><div className="panel-heading"><div><span className="eyebrow">CAMPOS DINÁMICOS</span><h2>Atributos</h2></div>{canManage && <button className="button secondary" onClick={() => openAttribute('new')}><Icon name="plus" />Agregar atributo</button>}</div>
{selected.attributes.length === 0 ? <div className="inline-empty">No hay atributos configurados para este tipo.</div> : <div className="attribute-list">{selected.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => canManage && openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
{attributeEditor && <form className="attribute-editor" onSubmit={saveAttribute}><div className="attribute-editor-heading"><h3>{attributeEditor === 'new' ? 'Nuevo atributo' : `Editar ${attributeEditor.name}`}</h3><button type="button" className="button text" onClick={() => setAttributeEditor(null)}>Cerrar</button></div><div className="form-grid"><label className="field"><span>Código</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} required minLength={2} maxLength={80} pattern="[a-z][a-z0-9_-]+" /></label><label className="field"><span>Nombre</span><input value={attributeName} onChange={(event) => setAttributeName(event.target.value)} required maxLength={160} /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label><label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} placeholder="m, bar, °C…" /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} required /></label></div>{attributeType === 'SELECT' && <label className="field"><span>Opciones <em>una por línea</em></span><textarea value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} required rows={4} placeholder={'Opción A\nOpción B'} /></label>}<div className="type-flags"><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Obligatorio</strong><small>Todo registro de este tipo debe completar el valor.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Atributo activo</strong><small>Desactivarlo conserva los valores históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving}>{saving ? 'Guardando…' : 'Guardar atributo'}</button></div></form>}
</div>}
return <section className="inventory-config-page">
<div className="page-heading">
<div>
<span className="eyebrow">ADMINISTRACIÓN</span>
<h1>Configuración de Inventarios</h1>
<p>Administrá la estructura, los tipos de Instalación/Subinstalación y las columnas que se completan en oficina o desde la APK.</p>
</div>
</div>}
{canManage && <Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Agregar registro</Link>}
</div>
{error && <Alert>{error}</Alert>}{success && <Alert type="success">{success}</Alert>}
<div className="panel" style={{ marginBottom: 18 }}>
<div className="panel-heading"><div><span className="eyebrow">MODELO VIGENTE</span><h2>Estructura física</h2><p className="section-copy">Empresa no es padre del Área. La Operadora/Concesionaria se vincula al Área con vigencia temporal.</p></div></div>
<div className="asset-browser-levels" aria-label="Estructura de Inventarios">
<div><span>1</span><strong>Área</strong><small>raíz territorial</small></div><i></i>
<div><span>2</span><strong>Yacimiento</strong><small>dentro del Área</small></div><i></i>
<div><span>3</span><strong>Instalación</strong><small>inventario real</small></div><i></i>
<div><span>4</span><strong>Subinstalación</strong><small>inventario real</small></div>
</div>
<div className="temporal-notice" style={{ marginTop: 14 }}><Icon name="users" /><p><strong>Empresa:</strong> maestro independiente. Puede cambiar la Operadora de un Área sin mover ni reescribir Yacimientos, Instalaciones o Subinstalaciones.</p></div>
</div>
<div className="dashboard-grid" style={{ alignItems: 'start' }}>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Instalación</h2><p className="section-copy">Precargados desde final_modelov2.xlsx y ampliables por Hidrocarburos.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('INSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
<div className="attribute-list">{installationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.sourceReference?.startsWith('F5:final_modelov2.xlsx') ? 'Precargado desde fuente autorizada' : 'Agregado por Hidrocarburos'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
</article>
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">CLASIFICACIONES</span><h2>Tipos de Subinstalación</h2><p className="section-copy">Cada tipo queda asociado a un tipo de Instalación.</p></div>{canManage && <button className="button primary" onClick={() => openNewFamily('SUBINSTALLATION')}><Icon name="plus" />Nuevo tipo</button>}</div>
<div className="attribute-list" style={{ maxHeight: 560, overflow: 'auto' }}>{subinstallationFamilies.filter((family) => family.isActive !== false).map((family) => <button type="button" className="attribute-card" key={family.id} onClick={() => openFamily(family)}><span className="asset-symbol"><Icon name="layers" size={16} /></span><span><strong>{family.name}</strong><small>{family.parentFamilyName ?? 'Sin Instalación padre'} · {family.findingCount ?? 0} Hallazgos</small></span><span className="tag">{family.assetCount ?? 0} registros</span><Icon name="chevron" /></button>)}</div>
</article>
</div>
<article className="panel" style={{ marginTop: 18 }}>
<div className="panel-heading"><div><span className="eyebrow">COLUMNAS / CAMPOS</span><h2>Información configurable</h2><p className="section-copy">Agregá las columnas que deben completarse para cada nivel. No modifica la jerarquía.</p></div>{canManage && selectedType && <button className="button primary" onClick={() => openAttribute('new')}><Icon name="plus" />Nueva columna</button>}</div>
<div className="quick-view-row" style={{ marginBottom: 16 }}>{LEVELS.map((level) => <button type="button" key={level.kind} className={selectedKind === level.kind ? 'active' : ''} onClick={() => { setSelectedKind(level.kind); setAttributeEditor(null); }}>{level.label}</button>)}</div>
<p className="section-copy">{LEVELS.find((item) => item.kind === selectedKind)?.description}</p>
{!selectedType ? <Alert>Este nivel todavía no tiene un tipo maestro activo.</Alert> : selectedType.attributes.length === 0 ? <div className="inline-empty">No hay columnas adicionales configuradas para {LEVELS.find((item) => item.kind === selectedKind)?.label}.</div> : <div className="attribute-list">{selectedType.attributes.map((attribute) => <button type="button" className={`attribute-card ${attribute.isActive ? '' : 'disabled'}`} key={attribute.id} onClick={() => openAttribute(attribute)}><span className="attribute-order">{attribute.sortOrder}</span><span><strong>{attribute.name}</strong><small>{attribute.code} · {attributeTypeLabel(attribute.dataType)}{attribute.unit ? ` · ${attribute.unit}` : ''}</small></span><span className="attribute-flags">{attribute.isRequired && <span className="tag">Obligatorio</span>}{!attribute.isActive && <span className="tag">Inactivo</span>}</span><Icon name="chevron" /></button>)}</div>}
</article>
<div className="panel" style={{ marginTop: 18 }}><div className="panel-heading"><div><span className="eyebrow">HALLAZGOS</span><h2>Catálogo separado, relación clara</h2><p className="section-copy">Los Hallazgos se crean y editan en su catálogo, y allí se vinculan al tipo de Instalación/Subinstalación correspondiente. OTROS permanece siempre disponible.</p></div><Link className="button secondary" to="/admin/finding-catalog">Abrir Catálogo de hallazgos <Icon name="chevron" /></Link></div></div>
{familyEditor && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveFamily}><div className="drawer-heading"><div><span className="eyebrow">CLASIFICACIÓN DE INVENTARIO</span><h2>{familyEditor === 'new' ? 'Nuevo tipo' : 'Editar tipo'}</h2></div><button type="button" className="icon-button" onClick={() => setFamilyEditor(null)}>×</button></div><div className="catalog-editor-fields">{familyEditor === 'new' && <label className="field"><span>Nivel</span><SearchableSelect value={familyLevel} onChange={(event) => { setFamilyLevel(event.target.value as 'INSTALLATION' | 'SUBINSTALLATION'); setFamilyParentId(''); }}><option value="INSTALLATION">Instalación</option><option value="SUBINSTALLATION">Subinstalación</option></SearchableSelect></label>}<label className="field"><span>Nombre</span><input value={familyName} onChange={(event) => setFamilyName(event.target.value)} maxLength={240} required placeholder={familyLevel === 'INSTALLATION' ? 'Ej.: Estación, Planta…' : 'Ej.: Tanque, Bomba…'} /></label>{familyLevel === 'SUBINSTALLATION' && <label className="field"><span>Tipo de Instalación padre</span><SearchableSelect value={familyParentId} onChange={(event) => setFamilyParentId(event.target.value)} required><option value="">Seleccionar</option>{installationFamilies.filter((family) => family.isActive !== false).map((family) => <option value={family.id} key={family.id}>{family.name}</option>)}</SearchableSelect></label>}{familyEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={familyActive} onChange={(event) => setFamilyActive(event.target.checked)} /><span><strong>Tipo disponible</strong><small>Al desactivarlo deja de ofrecerse en nuevas altas, sin borrar registros históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setFamilyEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !familyName.trim() || (familyLevel === 'SUBINSTALLATION' && !familyParentId)}>{saving ? 'Guardando…' : 'Guardar tipo'}</button></div></form></div>}
{attributeEditor && selectedType && <div className="modal-backdrop"><form className="detail-drawer catalog-drawer" onSubmit={saveAttribute}><div className="drawer-heading"><div><span className="eyebrow">COLUMNA DE {LEVELS.find((item) => item.kind === selectedKind)?.label.toUpperCase()}</span><h2>{attributeEditor === 'new' ? 'Nueva columna' : 'Editar columna'}</h2></div><button type="button" className="icon-button" onClick={() => setAttributeEditor(null)}>×</button></div><div className="catalog-editor-fields"><label className="field"><span>Nombre visible</span><input value={attributeName} onChange={(event) => { setAttributeName(event.target.value); if (attributeEditor === 'new') setAttributeCode(attributeCodeFromName(event.target.value)); }} maxLength={160} required /></label><label className="field"><span>Código interno</span><input value={attributeCode} onChange={(event) => setAttributeCode(event.target.value.toLowerCase())} disabled={attributeEditor !== 'new'} maxLength={80} required pattern="[a-z][a-z0-9_]*" /></label><label className="field"><span>Tipo de dato</span><SearchableSelect value={attributeType} onChange={(event) => setAttributeType(event.target.value as AssetAttributeDataType)}>{ATTRIBUTE_TYPES.map((item) => <option value={item.value} key={item.value}>{item.label}</option>)}</SearchableSelect></label>{attributeType === 'SELECT' && <label className="field"><span>Opciones</span><textarea rows={5} value={attributeOptions} onChange={(event) => setAttributeOptions(event.target.value)} placeholder="Una opción por línea" /></label>}<label className="field"><span>Unidad <em>opcional</em></span><input value={attributeUnit} onChange={(event) => setAttributeUnit(event.target.value)} maxLength={40} /></label><label className="field"><span>Orden</span><input type="number" value={attributeOrder} onChange={(event) => setAttributeOrder(Number(event.target.value))} min={0} max={10000} /></label><label className="check-row"><input type="checkbox" checked={attributeRequired} onChange={(event) => setAttributeRequired(event.target.checked)} /><span><strong>Campo obligatorio</strong><small>Debe completarse cuando se registra este nivel.</small></span></label>{attributeEditor !== 'new' && <label className="check-row"><input type="checkbox" checked={attributeActive} onChange={(event) => setAttributeActive(event.target.checked)} /><span><strong>Columna activa</strong><small>Desactivarla conserva datos históricos.</small></span></label>}</div><div className="form-actions"><button type="button" className="button secondary" onClick={() => setAttributeEditor(null)}>Cancelar</button><button className="button primary" disabled={saving || !attributeName.trim() || !attributeCode}>{saving ? 'Guardando…' : 'Guardar columna'}</button></div></form></div>}
</section>;
}
+64 -36
View File
@@ -15,14 +15,15 @@ import {
ASSET_OPERATIONAL_STATUSES,
ASSET_STATUSES,
} from '../features/assets/assetPresentation';
import { listAssets, listAssetTree, listAssetTypes } from '../lib/api';
import { listAssetTypes } from '../lib/api';
import type {
AssetInformationStatus,
AssetListItem,
AssetOperationalStatus,
AssetType,
PageMeta,
} from '../lib/api';
import { listRealInventory } from '../lib/inventoryBrowserApi';
import type { InventoryListItem, InventoryQuery } from '../lib/inventoryBrowserApi';
import { formatDate } from '../lib/format';
const quickViews = [
@@ -35,7 +36,7 @@ const quickViews = [
export function AssetsPage() {
const [urlParams, setUrlParams] = useSearchParams();
const operationalContext = useOperationalContext();
const [assets, setAssets] = useState<AssetListItem[]>([]);
const [assets, setAssets] = useState<InventoryListItem[]>([]);
const [types, setTypes] = useState<AssetType[]>([]);
const [meta, setMeta] = useState<PageMeta>({ page: 1, pageSize: 25, total: 0, totalPages: 0 });
const [loading, setLoading] = useState(true);
@@ -59,25 +60,39 @@ export function AssetsPage() {
const effectiveOperationalStatus = quick === 'out' ? 'OUT_OF_SERVICE' as AssetOperationalStatus : operationalStatus;
useEffect(() => {
listAssetTypes().then(setTypes).catch(() => undefined);
listAssetTypes().then((data) => setTypes(data.filter((type) => ['instalacion','subinstalacion'].includes(type.code.toLowerCase())))).catch(() => undefined);
}, []);
useEffect(() => {
if (view !== 'list') return;
setLoading(true); setError('');
listAssets({
page, pageSize: 25, search, typeId, status,
setLoading(true);
setError('');
listRealInventory({
page,
pageSize: 25,
search,
typeId,
status,
operationalStatus: effectiveOperationalStatus,
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
operationalAreaId,
operatorCompanyId,
needsValidation,
hasGeometry,
})
.then((response) => { setAssets(response.data); setMeta(response.meta); })
.catch((requestError) => setError(errorMessage(requestError)))
.finally(() => setLoading(false));
}, [view, page, search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
const filters: Parameters<typeof listAssetTree>[0] = useMemo(() => ({
search, typeId, status, operationalStatus: effectiveOperationalStatus,
operationalAreaId, operatorCompanyId, needsValidation, hasGeometry,
const filters: InventoryQuery = useMemo(() => ({
search,
typeId,
status,
operationalStatus: effectiveOperationalStatus,
operationalAreaId,
operatorCompanyId,
needsValidation,
hasGeometry,
}), [search, typeId, status, effectiveOperationalStatus, operationalAreaId, operatorCompanyId, needsValidation, hasGeometry]);
const update = (changes: Record<string, string | null>) => {
@@ -91,18 +106,25 @@ export function AssetsPage() {
const clearFilters = () => {
const next = new URLSearchParams();
if (view === 'list') next.set('view', 'list');
['section', 'companyId', 'parentId'].forEach((key) => {
const value = urlParams.get(key);
if (value) next.set(key, value);
});
setDraftSearch(''); setUrlParams(next);
const parentId=urlParams.get('parentId');
if (parentId) next.set('parentId',parentId);
setDraftSearch('');
setUrlParams(next);
};
const setPage = (value: number) => {
const next = new URLSearchParams(urlParams);
value > 1 ? next.set('page', String(value)) : next.delete('page');
setUrlParams(next);
};
const setPage = (value: number) => { const next = new URLSearchParams(urlParams); value > 1 ? next.set('page', String(value)) : next.delete('page'); setUrlParams(next); };
const advancedActive = Boolean(typeId || status || rawOperationalStatus);
return <section>
<div className="page-heading asset-center-heading">
<div><span className="eyebrow">INVENTARIOS</span><h1>Inventarios</h1><p>Consultá y administrá el inventario operativo de cada empresa desde un solo lugar.</p></div>
<div>
<span className="eyebrow">INVENTARIOS</span>
<h1>Inventarios</h1>
<p>Instancias reales registradas en campo, organizadas por Área Yacimiento Instalación Subinstalación.</p>
</div>
<PermissionGate permission="assets.create"><Link className="button primary" to="/inventarios/nuevo"><Icon name="plus" />Nuevo registro</Link></PermissionGate>
</div>
@@ -111,15 +133,15 @@ export function AssetsPage() {
<div className="asset-center-controls">
<form className="asset-center-search" onSubmit={applySearch}>
<Icon name="search" />
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar registro, código o identificación…" />
<input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar Inventario, código o identificación…" />
<button className="button primary" type="submit">Buscar</button>
</form>
<div className="quick-view-row" aria-label="Vistas rápidas">
{quickViews.map((item) => <button key={item.key} type="button" className={quick === item.key ? 'active' : ''} onClick={() => setQuick(item.key)}>{item.label}</button>)}
<button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>
{view === 'list' && <button type="button" className={advancedOpen || advancedActive ? 'advanced active' : 'advanced'} onClick={() => setAdvancedOpen((current) => !current)}>Más filtros</button>}
</div>
{(advancedOpen || advancedActive) && <div className="advanced-filter-panel">
<label className="field compact-field"><span>Tipo</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Todos</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
{view === 'list' && (advancedOpen || advancedActive) && <div className="advanced-filter-panel">
<label className="field compact-field"><span>Nivel</span><SearchableSelect value={typeId} onChange={(event) => update({ typeId: event.target.value || null })}><option value="">Instalaciones y Subinstalaciones</option>{types.map((type) => <option key={type.id} value={type.id}>{type.name}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado del dato</span><SearchableSelect value={status} onChange={(event) => update({ status: event.target.value || null })}><option value="">Todos</option>{ASSET_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<label className="field compact-field"><span>Estado operativo</span><SearchableSelect value={rawOperationalStatus} onChange={(event) => update({ operationalStatus: event.target.value || null, quick: quick === 'out' ? null : quick === 'all' ? null : quick })}><option value="">Todos</option>{ASSET_OPERATIONAL_STATUSES.map((item) => <option key={item.value} value={item.value}>{item.label}</option>)}</SearchableSelect></label>
<button type="button" className="button text filter-clear" onClick={clearFilters}>Limpiar filtros</button>
@@ -127,19 +149,25 @@ export function AssetsPage() {
</div>
{error && <Alert>{error}</Alert>}
{view === 'hierarchy' ? <AssetHierarchyView filters={filters} /> : loading ? <LoadingBlock label="Cargando inventario…" /> : assets.length === 0 ? <EmptyState title="No encontramos registros" text="Probá con otra búsqueda o cambiá los filtros seleccionados." /> : <div className="table-panel compact-assets-table">
<div className="table-summary"><strong>{meta.total} registro{meta.total === 1 ? '' : 's'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Registro</th><th>Tipo</th><th>Área / Operadora</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
{assets.map((asset) => <tr key={asset.id}>
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
<td><span className="tag">{asset.type.name}</span></td>
<td>{asset.operationalArea || asset.operatorCompany ? <span><strong className="table-primary">{asset.operationalArea?.name ?? 'Sin área'}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora'}</small></span> : <span className="muted">Sin contexto</span>}</td>
<td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td>
<td>{formatDate(asset.updatedAt)}</td>
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
</tr>)}
</tbody></table></div>
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
</div>}
{view === 'hierarchy'
? <AssetHierarchyView filters={filters} />
: loading
? <LoadingBlock label="Cargando Inventario real…" />
: assets.length === 0
? <EmptyState title="Todavía no hay Inventario real" text="Las Áreas y Yacimientos precargados son contexto. Las Instalaciones/Subinstalaciones aparecerán aquí cuando sean registradas realmente." />
: <div className="table-panel compact-assets-table">
<div className="table-summary"><strong>{meta.total} instancia{meta.total === 1 ? '' : 's'} real{meta.total === 1 ? '' : 'es'}</strong><span>Página {meta.page} de {Math.max(meta.totalPages, 1)}</span></div>
<div className="table-scroll"><table><thead><tr><th>Inventario</th><th>Nivel</th><th>Área / Operadora vigente</th><th>Estado</th><th>Actualizado</th><th aria-label="Acciones" /></tr></thead><tbody>
{assets.map((asset) => <tr key={asset.id}>
<td><div className="asset-cell"><span className="asset-symbol"><Icon name="layers" size={16} /></span><div><Link to={`/inventarios/${asset.id}`} className="table-primary">{asset.name}</Link><small>{asset.code}{asset.parent ? ` · en ${asset.parent.name}` : ''}</small></div></div></td>
<td><span className="tag">{asset.type.name}</span></td>
<td>{asset.operationalArea ? <span><strong className="table-primary">{asset.operationalArea.name}</strong><small className="cell-subtext">{asset.operatorCompany?.name ?? 'Sin operadora vigente'}</small></span> : <span className="muted">Sin Área</span>}</td>
<td><div className="dual-status"><span className={`status-badge ${assetStatusClass(asset.informationStatus)}`}>{assetStatusLabel(asset.informationStatus)}</span><small>{assetOperationalStatusLabel(asset.operationalStatus)}</small></div></td>
<td>{formatDate(asset.updatedAt)}</td>
<td className="action-cell"><Link className="icon-button" to={`/inventarios/${asset.id}`} aria-label={`Abrir ${asset.name}`}><Icon name="chevron" /></Link></td>
</tr>)}
</tbody></table></div>
<div className="pagination"><button className="button secondary" disabled={page <= 1} onClick={() => setPage(page - 1)}>Anterior</button><span>{meta.total ? `${(page - 1) * meta.pageSize + 1}${Math.min(page * meta.pageSize, meta.total)} de ${meta.total}` : '0 resultados'}</span><button className="button secondary" disabled={page >= meta.totalPages} onClick={() => setPage(page + 1)}>Siguiente</button></div>
</div>}
</section>;
}
+13 -12
View File
@@ -29,7 +29,7 @@ export function DashboardPage() {
return <section>
<div className="page-heading dashboard-heading">
<div><span className="eyebrow">RESUMEN OPERATIVO</span><h1>Buen día, {user?.firstName}</h1><p>Lo que requiere atención en Inspecciones, Hallazgos, verificaciones e Informes.</p></div>
<div><span className="eyebrow">RESUMEN OPERATIVO</span><h1>Buen día, {user?.firstName}</h1><p>Lo que requiere atención en Inspecciones, Actas, verificaciones e Informes.</p></div>
<span className={`health-pill ${health?.status === 'ok' ? 'ok' : ''}`}><span />{health?.status === 'ok' ? 'Sistema operativo' : 'Verificando sistema'}</span>
</div>
@@ -38,21 +38,21 @@ export function DashboardPage() {
<div className="dashboard-section-heading"><div><span className="eyebrow">HOY</span><h2>Qué requiere atención</h2></div></div>
<div className="stat-grid operational-stat-grid">
<Link className="stat-card" to="/inspecciones?status=PLANNED"><div className="stat-icon blue"><Icon name="calendar" /></div><div><small>INSPECCIONES PLANIFICADAS</small><strong>{summary.counts.plannedInspections}</strong><span>Pendientes de iniciar</span></div></Link>
<Link className="stat-card" to="/hallazgos?workflow=VERIFICATION_OVERDUE"><div className="stat-icon danger"><Icon name="alert" /></div><div><small>VERIFICACIONES VENCIDAS</small><strong>{summary.counts.overdueControls}</strong><span>Requieren control operativo</span></div></Link>
<Link className="stat-card" to="/hallazgos"><div className="stat-icon observed"><Icon name="alert" /></div><div><small>HALLAZGOS ABIERTOS</small><strong>{summary.counts.openFindings}</strong><span>Seguimiento técnico activo</span></div></Link>
<Link className="stat-card" to="/informes?view=pending"><div className="stat-icon violet"><Icon name="clipboard" /></div><div><small>ACTAS SIN INF</small><strong>{summary.counts.sealedActsWithoutReport}</strong><span>Actas selladas pendientes de Informe</span></div></Link>
<Link className="stat-card" to="/hallazgos?workflow=VERIFICATION_OVERDUE"><div className="stat-icon danger"><Icon name="alert" /></div><div><small>VERIFICACIONES VENCIDAS</small><strong>{summary.counts.overdueControls}</strong><span>Hallazgos que requieren control técnico</span></div></Link>
<Link className="stat-card" to="/seguimiento-actas"><div className="stat-icon observed"><Icon name="clipboard" /></div><div><small>ACTAS EN SEGUIMIENTO</small><strong>{summary.counts.actsInFollowUp}</strong><span>Respuestas, compromisos y regularización</span></div></Link>
<Link className="stat-card" to="/informes?view=pending"><div className="stat-icon violet"><Icon name="clipboard" /></div><div><small>ACTAS SIN INFORME</small><strong>{summary.counts.sealedActsWithoutReport}</strong><span>Actas selladas que todavía no tienen Informe</span></div></Link>
</div>
<div className="dashboard-section-heading compact"><div><span className="eyebrow">INVENTARIOS Y DOCUMENTOS</span><h2>Estado general</h2></div></div>
<div className="stat-grid secondary-stat-grid">
<Link className="stat-card compact" to="/inventarios"><div className="stat-icon blue"><Icon name="layers" /></div><div><small>INVENTARIO</small><strong>{summary.counts.totalAssets}</strong><span>Elementos registrados</span></div></Link>
<Link className="stat-card compact" to="/inventarios?quick=location"><div className="stat-icon gray"><Icon name="map" /></div><div><small>SIN UBICACIÓN</small><strong>{summary.counts.assetsWithoutGeometry}</strong><span>Sin geometría registrada</span></div></Link>
<Link className="stat-card compact" to="/informes"><div className="stat-icon gray"><Icon name="audit" /></div><div><small>INF EN PREPARACIÓN</small><strong>{summary.counts.reportsWorking}</strong><span>{summary.counts.reportsOfficialized} oficializados en GEDO</span></div></Link>
<Link className="stat-card compact" to="/inventarios"><div className="stat-icon blue"><Icon name="layers" /></div><div><small>INVENTARIO REAL</small><strong>{summary.counts.totalAssets}</strong><span>Instancias físicas registradas</span></div></Link>
<Link className="stat-card compact" to="/inventarios?quick=location"><div className="stat-icon gray"><Icon name="map" /></div><div><small>SIN UBICACIÓN</small><strong>{summary.counts.assetsWithoutGeometry}</strong><span>Inventarios reales sin geometría</span></div></Link>
<Link className="stat-card compact" to="/informes"><div className="stat-icon gray"><Icon name="audit" /></div><div><small>INFORMES EN PREPARACIÓN</small><strong>{summary.counts.reportsWorking}</strong><span>Informe creado, todavía no oficializado · {summary.counts.reportsOfficialized} oficializados</span></div></Link>
<Link className="stat-card compact" to="/hallazgos/planificacion"><div className="stat-icon green"><Icon name="calendar" /></div><div><small>PRÓXIMAS VERIFICACIONES</small><strong>{summary.counts.controlsNext30Days}</strong><span>{summary.counts.findingsWithoutControlDate} Hallazgos abiertos sin fecha de control</span></div></Link>
</div>
{hasPermission('inspection_findings.read') && <article className="panel upcoming-controls-panel">
<div className="panel-heading"><div><span className="eyebrow">AGENDA</span><h2>Próximos controles</h2><p className="section-copy">Hallazgos abiertos con una fecha de verificación definida.</p></div><Link className="text-link" to="/hallazgos/planificacion">Planificar verificaciones <Icon name="chevron" size={15} /></Link></div>
<div className="panel-heading"><div><span className="eyebrow">AGENDA TÉCNICA</span><h2>Próximos controles</h2><p className="section-copy">Hallazgos abiertos con una fecha de verificación definida. Este seguimiento técnico permite verificar regularización y detectar reincidencias.</p></div><Link className="text-link" to="/hallazgos/planificacion">Planificar verificaciones <Icon name="chevron" size={15} /></Link></div>
<div className="activity-list">
{summary.upcomingControls.length === 0 && <p className="muted">Todavía no hay controles programados.</p>}
{summary.upcomingControls.map((control) => <Link className="activity-item control-item" key={control.id} to={`/hallazgos/${control.id}`}><span className={`activity-dot ${control.nextControlOn < todayDate() ? 'overdue-dot' : ''}`} /><div><strong>{control.title}</strong><p>{control.assetCode} · {control.assetName} · {control.actCode}</p></div><time>{formatDateOnly(control.nextControlOn)}</time></Link>)}
@@ -61,15 +61,16 @@ export function DashboardPage() {
<div className="dashboard-grid">
<article className="panel">
<div className="panel-heading"><div><span className="eyebrow">ACTIVIDAD</span><h2>Últimos cambios</h2></div>{hasPermission('audit.read') && <Link className="text-link" to="/admin/audit">Ver auditoría <Icon name="chevron" size={15} /></Link>}</div>
<div className="activity-list">{summary.recentAudit.length === 0 ? <p className="muted">Todavía no hay actividad registrada.</p> : summary.recentAudit.map((event) => <div className="activity-item" key={event.id}><span className="activity-dot" /><div><strong>{actionLabel(event.action)}</strong><p>{event.actorUsername ?? 'Sistema'}{event.entityType ? ` · ${event.entityType}` : ''}</p></div><time>{formatDate(event.occurredAt)}</time></div>)}</div>
<div className="panel-heading"><div><span className="eyebrow">ACTIVIDAD DE CAMPO</span><h2>Últimas actividades de los inspectores</h2><p className="section-copy">Acciones operativas realizadas por inspectores asignados.</p></div></div>
<div className="activity-list">{summary.recentInspectorActivity.length === 0 ? <p className="muted">Todavía no hay actividad operativa de inspectores registrada.</p> : summary.recentInspectorActivity.map((event) => <div className="activity-item" key={event.id}><span className="activity-dot" /><div><strong>{actionLabel(event.action)}</strong><p>{event.actorUsername ?? 'Inspector'}{event.entityType ? ` · ${event.entityType}` : ''}</p></div><time>{formatDate(event.occurredAt)}</time></div>)}</div>
</article>
<article className="panel quick-panel">
<span className="eyebrow">ACCESOS RÁPIDOS</span><h2>Administración</h2>
<div className="quick-links">
<Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Inventario operativo organizado por empresa y área</small></span><Icon name="chevron" /></Link>
{hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Tipos y campos configurables</small></span><Icon name="chevron" /></Link>}
<Link to="/inventarios"><Icon name="layers" /><span><strong>Inventarios</strong><small>Instancias reales organizadas por empresa, área y jerarquía</small></span><Icon name="chevron" /></Link>
{hasPermission('asset_types.read') && <Link to="/admin/asset-types"><Icon name="layers" /><span><strong>Configuración de Inventarios</strong><small>Jerarquía, tipos y campos configurables</small></span><Icon name="chevron" /></Link>}
{hasPermission('finding_catalog.manage') && <Link to="/admin/finding-catalog"><Icon name="alert" /><span><strong>Catálogo de hallazgos</strong><small>Hallazgos aplicables según el tipo de Subinstalación</small></span><Icon name="chevron" /></Link>}
{hasPermission('users.read') && <Link to="/admin/users"><Icon name="users" /><span><strong>Usuarios</strong><small>Accesos y roles</small></span><Icon name="chevron" /></Link>}
</div>
<div className="system-footnote">API {health?.version ?? '—'} · Base {health?.database === 'ok' ? 'operativa' : 'sin verificar'}</div>
@@ -100,8 +100,6 @@ export function InspectionVisitsPage() {
{statusTabs.map((item) => <Link key={item.value || 'all'} className={status === item.value ? 'active' : ''} to={statusHref(item.value)}>{item.label}</Link>)}
</nav>
<div className="temporal-notice"><Icon name="clipboard" /><p><strong>Un recorrido, varias actas.</strong> La inspección representa la salida a un Área/Operadora y puede generar múltiples actas durante el recorrido. El inicio y cierre en campo siguen siendo exclusivos de la APK.</p></div>
<form className="toolbar survey-toolbar" onSubmit={applySearch}>
<label className="search-field"><Icon name="search" /><input value={draftSearch} onChange={(event) => setDraftSearch(event.target.value)} placeholder="Buscar por código" /><button>Buscar</button></label>
<OperationalFilters inspectorId={inspectorId} dateFrom={dateFrom} dateTo={dateTo} onChange={updateFilter} />
+42 -26
View File
@@ -18,7 +18,7 @@ import type {
InventoryStructureParent,
} from '../lib/inventoryStructureApi';
const KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [
const STRUCTURE_KINDS: Array<{ kind: InventoryStructureKind; label: string; help: string; step: number }> = [
{ kind: 'AREA', label: 'Área', help: 'Nivel territorial raíz.', step: 1 },
{ kind: 'YACIMIENTO', label: 'Yacimiento', help: 'Debe pertenecer a un Área.', step: 2 },
{ kind: 'INSTALACION', label: 'Instalación', help: 'Debe pertenecer a un Yacimiento.', step: 3 },
@@ -32,7 +32,8 @@ const childKindByParentType: Record<string, InventoryStructureKind | undefined>
};
function kindLabel(kind: InventoryStructureKind): string {
return KINDS.find((item) => item.kind === kind)?.label ?? kind;
if (kind === 'EMPRESA') return 'Empresa';
return STRUCTURE_KINDS.find((item) => item.kind === kind)?.label ?? kind;
}
export function InventoryCreatePage() {
@@ -72,7 +73,7 @@ export function InventoryCreatePage() {
}, [contextParentId]);
useEffect(() => {
if (kind === 'AREA') {
if (kind === 'AREA' || kind === 'EMPRESA') {
setParents([]);
setParentId('');
return;
@@ -130,7 +131,7 @@ export function InventoryCreatePage() {
const save = async (event: FormEvent) => {
event.preventDefault();
const requiresParent = kind !== 'AREA';
const requiresParent = kind !== 'AREA' && kind !== 'EMPRESA';
const requiresFamily = kind === 'INSTALACION' || kind === 'SUBINSTALACION';
if (requiresParent && !parentId) {
setError(`Seleccioná el ${kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación'} padre.`);
@@ -160,10 +161,13 @@ export function InventoryCreatePage() {
}
};
if (loading) return <LoadingBlock label="Preparando alta de Inventario…" />;
if (loading) return <LoadingBlock label="Preparando alta…" />;
const isIndependentCompany = kind === 'EMPRESA';
const parentLabel = kind === 'YACIMIENTO' ? 'Área' : kind === 'INSTALACION' ? 'Yacimiento' : 'Instalación';
const currentStep = KINDS.find((item) => item.kind === kind)?.step ?? 1;
const currentStep = STRUCTURE_KINDS.find((item) => item.kind === kind)?.step ?? 1;
const identificationStep = isIndependentCompany ? '2' : kind === 'AREA' ? '2' : kind === 'YACIMIENTO' ? '3' : '4';
const requiresParent = kind !== 'AREA' && kind !== 'EMPRESA';
return <section className="narrow-section asset-detail-page">
<nav className="breadcrumb asset-detail-breadcrumb" aria-label="Ruta del inventario">
@@ -172,9 +176,9 @@ export function InventoryCreatePage() {
<div className="page-heading asset-editor-heading">
<div>
<span className="eyebrow">INVENTARIO</span>
<h1>Agregar a la estructura</h1>
<p>La estructura oficial es Área Yacimiento Instalación Subinstalación. Elegí el nivel y el sistema te guía con los vínculos válidos.</p>
<span className="eyebrow">CONFIGURACIÓN E INVENTARIO</span>
<h1>Agregar registro</h1>
<p>Empresa es un maestro independiente. La estructura física es Área Yacimiento Instalación Subinstalación.</p>
</div>
</div>
@@ -182,9 +186,18 @@ export function InventoryCreatePage() {
<div className="panel" style={{ marginBottom: 18 }}>
<div className="form-section">
<div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Sólo se pueden crear los cuatro niveles estructurales definidos para DH.</p></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(4, minmax(0, 1fr))', gap: 10 }}>
{KINDS.map((item) => <button
<div><h2>1. ¿Qué querés crear?</h2><p className="section-copy">Creá una Empresa independiente o agregá un nivel a la estructura física.</p></div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(5, minmax(0, 1fr))', gap: 10 }}>
<button
type="button"
className={`button ${kind === 'EMPRESA' ? 'primary' : 'secondary'}`}
onClick={() => chooseKind('EMPRESA')}
style={{ minHeight: 76, display: 'flex', flexDirection: 'column', alignItems: 'flex-start', justifyContent: 'center', gap: 3 }}
>
<strong>Empresa</strong>
<small>Maestro independiente.</small>
</button>
{STRUCTURE_KINDS.map((item) => <button
key={item.kind}
type="button"
className={`button ${kind === item.kind ? 'primary' : 'secondary'}`}
@@ -195,15 +208,18 @@ export function InventoryCreatePage() {
<small>{item.help}</small>
</button>)}
</div>
<div className="temporal-notice" style={{ marginTop: 12 }}>
{isIndependentCompany ? <div className="temporal-notice" style={{ marginTop: 12 }}>
<Icon name="users" />
<p><strong>Empresa:</strong> no forma parte de la jerarquía física. Su vínculo con un Área se administra como relación temporal de operación/concesión.</p>
</div> : <div className="temporal-notice" style={{ marginTop: 12 }}>
<Icon name="layers" />
<p><strong>Ruta:</strong> {KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')}</p>
</div>
<p><strong>Ruta física:</strong> {STRUCTURE_KINDS.slice(0, currentStep).map((item) => item.label).join(' → ')}</p>
</div>}
</div>
</div>
<form className="panel form-panel" onSubmit={save}>
{kind !== 'AREA' && <div className="form-section">
{requiresParent && <div className="form-section">
<div><h2>2. Ubicación en la estructura</h2><p className="section-copy">Primero elegí el {parentLabel} al que pertenece este registro.</p></div>
<label className="field">
<span>Buscar {parentLabel.toLowerCase()}</span>
@@ -220,24 +236,24 @@ export function InventoryCreatePage() {
</div>}
{(kind === 'INSTALACION' || kind === 'SUBINSTALACION') && <div className="form-section">
<div><h2>3. Familia técnica</h2><p className="section-copy">La familia no crea otro nivel. Sirve para aplicar exactamente los Hallazgos del Excel que corresponden.</p></div>
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación seleccionada todavía no tiene una familia técnica F3.1. Revisala antes de crear una Subinstalación.</Alert> : <label className="field">
<span>Familia de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span>
<div><h2>3. Clasificación técnica</h2><p className="section-copy">La clasificación no crea otro nivel: define el tipo de Instalación/Subinstalación y los Hallazgos aplicables.</p></div>
{kind === 'SUBINSTALACION' && !selectedParent?.inventoryFamily ? <Alert>La Instalación seleccionada todavía no tiene una clasificación técnica. Revisala antes de crear una Subinstalación.</Alert> : <label className="field">
<span>Tipo de {kindLabel(kind).toLowerCase()} <em>obligatorio</em></span>
<select value={familyId} onChange={(event) => setFamilyId(event.target.value)} required>
<option value="">Seleccionar familia</option>
<option value="">Seleccionar tipo</option>
{families.map((family) => <option key={family.id} value={family.id}>{family.name}</option>)}
</select>
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && <small>Se muestran sólo las subinstalaciones válidas para {selectedParent.inventoryFamily.name}.</small>}
{kind === 'SUBINSTALACION' && selectedParent?.inventoryFamily && <small>Se muestran sólo las Subinstalaciones válidas para {selectedParent.inventoryFamily.name}.</small>}
</label>}
{selectedFamily && <div className="context-create-banner" style={{ alignItems: 'flex-start' }}>
<Icon name="alert" />
<div style={{ flex: 1 }}>
<strong>Hallazgos asociados automáticamente</strong>
<span>{familyFindings ? `${familyFindings.count} controles del Excel para ${selectedFamily.name}` : 'Cargando catálogo asociado…'}</span>
<span>{familyFindings ? `${familyFindings.count} Hallazgos aplicables para ${selectedFamily.name}` : 'Cargando catálogo asociado…'}</span>
{familyFindings && familyFindings.items.length > 0 && <ul style={{ margin: '8px 0 0', paddingLeft: 18 }}>
{familyFindings.items.slice(0, 7).map((item) => <li key={item.id}>{item.title}</li>)}
{familyFindings.items.length > 7 && <li><strong>+ {familyFindings.items.length - 7} hallazgos más</strong></li>}
{familyFindings.items.length > 7 && <li><strong>+ {familyFindings.items.length - 7} Hallazgos más</strong></li>}
</ul>}
</div>
</div>}
@@ -249,18 +265,18 @@ export function InventoryCreatePage() {
</div>}
<div className="form-section">
<div><h2>{kind === 'AREA' ? '2' : kind === 'YACIMIENTO' ? '3' : '4'}. Identificación</h2><p className="section-copy">Usá el nombre real de campo. El código DH puede generarse automáticamente.</p></div>
<div><h2>{identificationStep}. Identificación</h2><p className="section-copy">{isIndependentCompany ? 'Registrá la denominación de la Empresa.' : 'Usá el nombre real de campo.'} El código DH puede generarse automáticamente.</p></div>
<div className="form-grid">
<label className="field"><span>Nombre <em>obligatorio</em></span><input value={name} onChange={(event) => setName(event.target.value)} required maxLength={200} placeholder={`Nombre de ${kindLabel(kind).toLowerCase()}`} /></label>
<label className="field"><span>Código DH <em>opcional</em></span><input value={code} onChange={(event) => setCode(event.target.value.toUpperCase())} maxLength={120} pattern="[A-Z0-9][A-Z0-9._/-]*" placeholder="Dejar vacío para generar automáticamente" /><small>Si no lo informás, DH genera un código único.</small></label>
<label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} placeholder="Nombre usado por los inspectores en campo" /></label>
<label className="field"><span>Nombre habitual / sobrenombre <em>opcional</em></span><input value={commonName} onChange={(event) => setCommonName(event.target.value)} maxLength={200} placeholder={isIndependentCompany ? 'Nombre habitual o abreviado' : 'Nombre usado por los inspectores en campo'} /></label>
</div>
<label className="field"><span>Descripción <em>opcional</em></span><textarea value={description} onChange={(event) => setDescription(event.target.value)} rows={2} maxLength={4000} /></label>
</div>
<div className="form-actions">
<Link className="button secondary" to="/inventarios">Cancelar</Link>
<button className="button primary" disabled={saving || !name.trim() || (kind !== 'AREA' && !parentId) || ((kind === 'INSTALACION' || kind === 'SUBINSTALACION') && !familyId)}>
<button className="button primary" disabled={saving || !name.trim() || (requiresParent && !parentId) || ((kind === 'INSTALACION' || kind === 'SUBINSTALACION') && !familyId)}>
<Icon name="check" />{saving ? 'Creando…' : `Crear ${kindLabel(kind)}`}
</button>
</div>