Compare commits
13
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b89fd7c4d6 | ||
|
|
4dce65d577 | ||
|
|
5c78aee5e9 | ||
|
|
a985fba96a | ||
|
|
d690040a10 | ||
|
|
35d4630581 | ||
|
|
1dc3282055 | ||
|
|
7875fea3ca | ||
|
|
0d5bdd25ce | ||
|
|
a75e9bdcb0 | ||
|
|
3027e9a8c8 | ||
|
|
09a190532c | ||
|
|
8a8ab47453 |
@@ -1,9 +1,11 @@
|
||||
name: Android APK
|
||||
# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta.
|
||||
# F5: genera una APK debug verificable contra la API productiva F5.
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- 'main'
|
||||
- 'release/f5-android-test'
|
||||
- 'feature/f2-2*'
|
||||
- 'feature/f2-3*'
|
||||
- 'feature/f2-4*'
|
||||
@@ -16,6 +18,8 @@ on:
|
||||
paths:
|
||||
- 'android-app/**'
|
||||
- 'api-v3/src/auth/**'
|
||||
- 'api-v3/src/inspection-visits/**'
|
||||
- 'api-v3/src/inspection-acts/**'
|
||||
- '.github/workflows/android.yml'
|
||||
workflow_dispatch:
|
||||
|
||||
@@ -58,7 +62,7 @@ jobs:
|
||||
- name: Upload APK
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: DH-Inspeccion-F3.2-0.12.0-debug
|
||||
name: DH-Inspeccion-F5-0.13.0-debug
|
||||
path: android-app/app/build/outputs/apk/debug/app-debug.apk
|
||||
if-no-files-found: error
|
||||
retention-days: 14
|
||||
retention-days: 30
|
||||
|
||||
@@ -62,8 +62,159 @@ jobs:
|
||||
while IFS= read -r -d '' script; do
|
||||
bash -n "$script"
|
||||
done < <(find scripts -type f -name '*.sh' -print0)
|
||||
- name: Validate deploy preflight parity
|
||||
run: |
|
||||
grep -Fq -- '$STAGE/docker-compose.yml:/docker-compose.yml:ro' scripts/deploy-github.sh
|
||||
grep -Fq -- '$STAGE/web-v2:/web-v2:ro' scripts/deploy-github.sh
|
||||
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
|
||||
|
||||
@@ -12,8 +12,8 @@ android {
|
||||
applicationId = "com.korexlabs.dhinspeccion"
|
||||
minSdk = 26
|
||||
targetSdk = 36
|
||||
versionCode = 19
|
||||
versionName = "0.12.0"
|
||||
versionCode = 20
|
||||
versionName = "0.13.0"
|
||||
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
vectorDrawables.useSupportLibrary = true
|
||||
|
||||
+1
-1
@@ -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 };
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +44,8 @@ const STRUCTURAL_CHILD: Record<string, string | undefined> = {
|
||||
instalacion: 'subinstalacion',
|
||||
};
|
||||
|
||||
const OTHER_FAMILY_SQL = "source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR source_reference LIKE 'F5:SYSTEM:OTHER:%'";
|
||||
|
||||
@Injectable()
|
||||
export class F3FieldInventoryStructureService {
|
||||
constructor(
|
||||
@@ -122,7 +124,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(`
|
||||
@@ -189,10 +204,10 @@ export class F3FieldInventoryStructureService {
|
||||
return this.dataSource.query(`
|
||||
SELECT id,code,name,level,information_labels AS "informationLabels",
|
||||
source_reference AS "sourceReference",
|
||||
(source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
|
||||
(${OTHER_FAMILY_SQL}) AS "isOther"
|
||||
FROM inventory_families
|
||||
WHERE level='INSTALLATION' AND is_active=true
|
||||
ORDER BY (source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,name,code
|
||||
ORDER BY (${OTHER_FAMILY_SQL}) ASC,name,code
|
||||
`) as Promise<FamilyRow[]>;
|
||||
}
|
||||
if (expectedTypeCode === 'subinstalacion') {
|
||||
@@ -206,13 +221,13 @@ export class F3FieldInventoryStructureService {
|
||||
SELECT family.id,family.code,family.name,family.level,
|
||||
family.information_labels AS "informationLabels",
|
||||
family.source_reference AS "sourceReference",
|
||||
(family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') AS "isOther"
|
||||
(family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') AS "isOther"
|
||||
FROM inventory_family_parent_rules rule
|
||||
JOIN inventory_families family ON family.id=rule.child_family_id
|
||||
WHERE rule.parent_family_id=$1::uuid
|
||||
AND family.level='SUBINSTALLATION'
|
||||
AND family.is_active=true
|
||||
ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%') ASC,family.name,family.code
|
||||
ORDER BY (family.source_reference LIKE 'SYSTEM:F3.1:OTHER%' OR family.source_reference LIKE 'F5:SYSTEM:OTHER:%') ASC,family.name,family.code
|
||||
`, [parent.inventoryFamilyId]) as Promise<FamilyRow[]>;
|
||||
}
|
||||
return [];
|
||||
|
||||
@@ -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
@@ -1,2 +1,2 @@
|
||||
export const API_VERSION = '0.25.0-1';
|
||||
export const API_PHASE = 'F3.2';
|
||||
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/);
|
||||
});
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { API_PHASE, API_VERSION } from '../../src/version';
|
||||
|
||||
test('health metadata reports the current F5 release', () => {
|
||||
assert.equal(API_PHASE, 'F5');
|
||||
assert.equal(API_VERSION, '0.26.0-1');
|
||||
});
|
||||
@@ -0,0 +1,28 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
function mountedRepoFile(path: string): string {
|
||||
return readFileSync(resolve(process.cwd(), '..', path), 'utf8');
|
||||
}
|
||||
|
||||
test('F5 Android test cut targets production API and has a distinct installable debug version', () => {
|
||||
const gradle = mountedRepoFile('android-app/app/build.gradle.kts');
|
||||
|
||||
assert.match(gradle, /versionCode = 20/);
|
||||
assert.match(gradle, /versionName = "0\.13\.0"/);
|
||||
assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//);
|
||||
assert.match(gradle, /applicationIdSuffix = "\.debug"/);
|
||||
});
|
||||
|
||||
test('F5 field inventory exposes Other families as reviewable choices to Android', () => {
|
||||
const service = readFileSync(
|
||||
resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'),
|
||||
'utf8',
|
||||
);
|
||||
|
||||
assert.match(service, /F5:SYSTEM:OTHER:%/);
|
||||
assert.match(service, /AS "isOther"/);
|
||||
assert.match(service, /isOtherFamily: family\.isOther/);
|
||||
});
|
||||
@@ -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/);
|
||||
});
|
||||
@@ -183,6 +183,9 @@ docker build --target builder -t "$API_TEST_IMAGE" "$STAGE/api-v3" </dev/null
|
||||
docker run --rm \
|
||||
-v "$STAGE/api-v3/test:/app/test:ro" \
|
||||
-v "$STAGE/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \
|
||||
-v "$STAGE/docker-compose.yml:/docker-compose.yml:ro" \
|
||||
-v "$STAGE/web-v2:/web-v2:ro" \
|
||||
-v "$STAGE/android-app:/android-app:ro" \
|
||||
"$API_TEST_IMAGE" npm test </dev/null
|
||||
|
||||
echo
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "dhv2-web",
|
||||
"version": "0.20.0-4",
|
||||
"version": "0.21.0-1",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
|
||||
@@ -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,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';
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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' },
|
||||
];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)}`);
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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} />
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user