From e4acb7bebf26264503e700f4e3b1cd21b4af7672 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:06:42 -0300 Subject: [PATCH 01/26] fix(f6.1): block operator changes through inventory context --- api-v3/src/asset-master/dto/change-asset-context.dto.ts | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/api-v3/src/asset-master/dto/change-asset-context.dto.ts b/api-v3/src/asset-master/dto/change-asset-context.dto.ts index 831a70b..0075862 100644 --- a/api-v3/src/asset-master/dto/change-asset-context.dto.ts +++ b/api-v3/src/asset-master/dto/change-asset-context.dto.ts @@ -1,5 +1,5 @@ import { Transform, Type } from 'class-transformer'; -import { IsDate, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; +import { IsDate, IsEmpty, IsOptional, IsString, IsUUID, MaxLength, MinLength } from 'class-validator'; export class ChangeAssetContextDto { @IsOptional() @@ -10,8 +10,13 @@ export class ChangeAssetContextDto { @IsUUID('4') operationalAreaId?: string | null; + /** + * Compatibilidad de contrato únicamente. F5/F6 separa la ubicación física + * del Inventario de la Operadora temporal del Área, por lo que este valor no + * puede modificarse desde un cambio de contexto del Inventario. + */ @IsOptional() - @IsUUID('4') + @IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' }) operatorCompanyId?: string | null; @IsOptional() From 517f934d5ef82b58c592e93f22a0f69fcbb2b8d1 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:06:54 -0300 Subject: [PATCH 02/26] fix(f6.1): keep operator snapshot immutable on inventory updates --- api-v3/src/asset-master/dto/update-asset.dto.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/api-v3/src/asset-master/dto/update-asset.dto.ts b/api-v3/src/asset-master/dto/update-asset.dto.ts index 144fad5..918c1c6 100644 --- a/api-v3/src/asset-master/dto/update-asset.dto.ts +++ b/api-v3/src/asset-master/dto/update-asset.dto.ts @@ -1,5 +1,6 @@ import { Transform } from 'class-transformer'; import { + IsEmpty, IsObject, IsOptional, IsString, @@ -47,8 +48,12 @@ export class UpdateAssetDto { @IsUUID('4') operationalAreaId?: string | null; + /** + * Snapshot histórico/de compatibilidad. La Operadora vigente pertenece a la + * relación temporal Área↔Empresa y no puede editarse como propiedad física. + */ @IsOptional() - @IsUUID('4') + @IsEmpty({ message: 'La Operadora se administra en la relación temporal del Área, no en el Inventario' }) operatorCompanyId?: string | null; @IsOptional() From 44f60028693d838a77241111fd19061c71a8de06 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:07:17 -0300 Subject: [PATCH 03/26] fix(f6.1): resolve temporal operator from area relation history --- .../asset-master/asset-temporal.service.ts | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/api-v3/src/asset-master/asset-temporal.service.ts b/api-v3/src/asset-master/asset-temporal.service.ts index 215d2f1..d1e53eb 100644 --- a/api-v3/src/asset-master/asset-temporal.service.ts +++ b/api-v3/src/asset-master/asset-temporal.service.ts @@ -137,21 +137,41 @@ export class AssetTemporalService { if (!row) throw temporalAssetNotFound(); const asOf = new Date(query.at); row.asOf = asOf; + + // Physical context is reconstructed from the Inventory history, while the + // operator is resolved independently from the temporal Area↔Empresa ledger. + // This keeps historical operator changes from rewriting the Inventory. const [context] = (await this.dataSource.query(` SELECT CASE WHEN parent.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',parent.id,'code',parent.code,'name',parent.name) END AS parent, CASE WHEN area.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) END AS "operationalArea", - CASE WHEN company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) END AS "operatorCompany" + CASE WHEN operator_company.id IS NULL THEN NULL ELSE JSONB_BUILD_OBJECT( + 'id',operator_company.id,'code',operator_company.code,'name',operator_company.name + ) END AS "operatorCompany" FROM asset_context_history history LEFT JOIN assets parent ON parent.id=history.parent_id LEFT JOIN assets area ON area.id=history.operational_area_id - LEFT JOIN assets company ON company.id=history.operator_company_id + LEFT JOIN LATERAL ( + SELECT relation.company_id + FROM area_company_relations relation + WHERE relation.area_id=history.operational_area_id + AND relation.relation_role='OPERATOR' + AND relation.valid_from <= $2 + AND (relation.valid_until IS NULL OR relation.valid_until > $2) + ORDER BY relation.valid_from DESC, relation.created_at DESC, relation.id DESC + LIMIT 1 + ) operator_relation ON true + LEFT JOIN assets operator_company ON operator_company.id=operator_relation.company_id WHERE history.asset_id=$1 AND history.valid_from <= $2 AND (history.valid_until IS NULL OR history.valid_until > $2) ORDER BY history.valid_from DESC LIMIT 1 - `, [assetId, asOf])) as Array<{ parent: Record | null; operationalArea: Record | null; operatorCompany: Record | null }>; + `, [assetId, asOf])) as Array<{ + parent: Record | null; + operationalArea: Record | null; + operatorCompany: Record | null; + }>; if (context && row.snapshot) { row.snapshot = { ...row.snapshot, From 33c0c6b8f6a94b09adc3a8d2dedb93f7b4854f30 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:07:52 -0300 Subject: [PATCH 04/26] fix(f6.1): preserve inventory snapshots when operator changes --- .../asset-operational-relations.service.ts | 23 +++++-------------- 1 file changed, 6 insertions(+), 17 deletions(-) diff --git a/api-v3/src/asset-master/asset-operational-relations.service.ts b/api-v3/src/asset-master/asset-operational-relations.service.ts index 09670c0..ae547aa 100644 --- a/api-v3/src/asset-master/asset-operational-relations.service.ts +++ b/api-v3/src/asset-master/asset-operational-relations.service.ts @@ -170,20 +170,9 @@ export class AssetOperationalRelationsService { RETURNING id `, [dto.areaId, dto.companyId, dto.relationRole, dto.participationPercent ?? null, dto.legalInstrument ?? null, dto.sourceDocumentId ?? null, dto.reason, principal.userId])) as Array<{ id: string }>; - // 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]); - } - + // F5/F6: changing the Area operator is a temporal relation event only. + // Existing Inventory keeps its creation/historical operator snapshot and + // physical hierarchy unchanged. Runtime ownership must resolve this row. const created = await this.loadRelation(manager, row.id); await this.audit.record({ ...administrationAuditContext(principal, request), @@ -192,7 +181,7 @@ export class AssetOperationalRelationsService { entityId: row.id, afterData: this.auditView(created), metadata: dto.relationRole === AreaOrganizationRole.OPERATOR - ? { inventoryHierarchyChanged:false, operatorSnapshotSynchronized:true } + ? { inventoryHierarchyChanged:false, operatorSnapshotPreserved:true } : undefined, }, manager); return created; @@ -223,8 +212,8 @@ export class AssetOperationalRelationsService { }); } - // F5: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company. - // Ending an operator relation must never be blocked by existing Inventory. + // F5/F6: physical Inventory belongs to Area/Yacimiento hierarchy, not to Company. + // Ending an operator relation never moves, rewrites or blocks existing Inventory. await manager.query(` UPDATE area_company_relations SET valid_until = CURRENT_TIMESTAMP, From 5738306a14989d0101b76153a7a50f3a253486be Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:09:04 -0300 Subject: [PATCH 05/26] fix(f6.1): resolve verification operator from temporal area relation --- .../inspection-verifications.service.ts | 73 ++++++++++++++----- 1 file changed, 55 insertions(+), 18 deletions(-) diff --git a/api-v3/src/inspection-verifications/inspection-verifications.service.ts b/api-v3/src/inspection-verifications/inspection-verifications.service.ts index a2fcdcc..ab32365 100644 --- a/api-v3/src/inspection-verifications/inspection-verifications.service.ts +++ b/api-v3/src/inspection-verifications/inspection-verifications.service.ts @@ -66,9 +66,6 @@ interface LockedFindingRow { assetId: string; assetCode: string; assetName: string; - companyId: string | null; - companyCode: string | null; - companyName: string | null; areaId: string | null; areaCode: string | null; areaName: string | null; @@ -76,6 +73,12 @@ interface LockedFindingRow { status: string; } +interface PlannedOperatorRow { + companyId: string; + companyCode: string; + companyName: string; +} + @Injectable() export class InspectionVerificationsService { constructor( @@ -142,8 +145,19 @@ export class InspectionVerificationsService { INNER JOIN inspection_visits source_visit ON source_visit.id = act.visit_id INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id - LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets area ON area.id = asset.operational_area_id + LEFT JOIN LATERAL ( + SELECT operator_company.id,operator_company.code,operator_company.name + FROM area_company_relations relation + INNER JOIN assets operator_company ON operator_company.id=relation.company_id + WHERE relation.area_id=asset.operational_area_id + AND relation.relation_role='OPERATOR' + AND relation.valid_from <= CURRENT_TIMESTAMP + AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP) + AND operator_company.information_status<>'INACTIVE' + ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC + LIMIT 1 + ) company ON true ${activeVisitJoin} `; @@ -495,15 +509,11 @@ export class InspectionVerificationsService { finding.next_control_on AS "nextControlOn", asset.code AS "assetCode", asset.name AS "assetName", - company.id AS "companyId", - company.code AS "companyCode", - company.name AS "companyName", area.id AS "areaId", area.code AS "areaCode", area.name AS "areaName" FROM inspection_findings finding INNER JOIN assets asset ON asset.id = finding.asset_id - LEFT JOIN assets company ON company.id = asset.operator_company_id LEFT JOIN assets area ON area.id = asset.operational_area_id WHERE finding.id = ANY($1::uuid[]) FOR UPDATE OF finding @@ -519,25 +529,51 @@ export class InspectionVerificationsService { message: `${invalid.code} necesita estar abierto y tener una fecha de control para planificar su verificación`, }); } - const missingContext = rows.find((row) => !row.companyId || !row.areaId); - if (missingContext) { + const missingArea = rows.find((row) => !row.areaId); + if (missingArea) { throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', - message: `${missingContext.code} necesita empresa y área operativa antes de planificar la visita`, + message: `${missingArea.code} necesita un Área física antes de planificar la visita`, }); } - const companyId = rows[0]?.companyId; const areaId = rows[0]?.areaId; - if (!companyId || !areaId) { - throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan empresa y área operativa' }); + if (!areaId) { + throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_REQUIRED', message: 'Los hallazgos necesitan un Área física' }); } - if (rows.some((row) => row.companyId !== companyId || row.areaId !== areaId)) { + if (rows.some((row) => row.areaId !== areaId)) { throw new BadRequestException({ code: 'VERIFICATION_CONTEXT_MIXED', - message: 'Seleccioná hallazgos de una misma empresa y área para crear una visita de verificación', + message: 'Seleccioná hallazgos de una misma Área para crear una visita de verificación', }); } + // A verification visit is a new operational act. Its operator is therefore + // the Company valid for the Area at the planned visit date, never the + // historical compatibility snapshot stored on the Inventory. + const [operator] = await manager.query(` + SELECT + company.id AS "companyId", + company.code AS "companyCode", + company.name AS "companyName" + FROM area_company_relations relation + INNER JOIN assets company ON company.id=relation.company_id + WHERE relation.area_id=$1::uuid + AND relation.relation_role='OPERATOR' + AND relation.valid_from <= $2::timestamptz + AND (relation.valid_until IS NULL OR relation.valid_until > $2::timestamptz) + AND company.information_status<>'INACTIVE' + ORDER BY relation.valid_from DESC,relation.created_at DESC,relation.id DESC + LIMIT 1 + FOR SHARE OF relation + `, [areaId, plannedStartAt]) as PlannedOperatorRow[]; + if (!operator) { + throw new ConflictException({ + code: 'VERIFICATION_OPERATOR_NOT_ACTIVE', + message: 'El Área no tiene una Operadora vigente para la fecha prevista de la verificación', + }); + } + const companyId = operator.companyId; + const activeLinks = await manager.query(` SELECT verification_link.finding_id AS "findingId", visit.code FROM inspection_finding_verification_visits verification_link @@ -552,7 +588,7 @@ export class InspectionVerificationsService { }); } - const companyName = rows[0]?.companyName ?? 'Empresa'; + const companyName = operator.companyName; const areaName = rows[0]?.areaName ?? 'Área'; const code = await nextInspectionVisitCode(manager, plannedStartAt); const findingCodes = rows.map((row) => row.code).join(', '); @@ -637,6 +673,7 @@ export class InspectionVerificationsService { areaId, findingIds, assetIds, + operatorSource: 'area_company_relations', }, }, manager); @@ -647,7 +684,7 @@ export class InspectionVerificationsService { status: visit.status, plannedStartAt: visit.plannedStartAt, }, - company: { id: companyId, code: rows[0]?.companyCode ?? '', name: companyName }, + company: { id: companyId, code: operator.companyCode, name: companyName }, area: { id: areaId, code: rows[0]?.areaCode ?? '', name: areaName }, findingCount: findingIds.length, assetCount: assetIds.length, From ec8a9be99effb467a988c4ce937a32e62789b8ef Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:12:27 -0300 Subject: [PATCH 06/26] test(f6.1): enforce operator relation without inventory rewrite --- .../test/unit/asset-operational-relations-integrity.test.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/api-v3/test/unit/asset-operational-relations-integrity.test.ts b/api-v3/test/unit/asset-operational-relations-integrity.test.ts index 9e66d53..dbbf197 100644 --- a/api-v3/test/unit/asset-operational-relations-integrity.test.ts +++ b/api-v3/test/unit/asset-operational-relations-integrity.test.ts @@ -33,13 +33,15 @@ test('D5.3 API enforces active relation, exclusive pair and physical ancestry', assert.match(assets, /OPERATIONAL_ANCHOR_IN_USE/); }); -test('D5.3/F5 relation lifecycle stays historical and audited without making Empresa physical ownership', async () => { +test('D5.3/F6.1 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, /Ending an operator relation must never be blocked by existing Inventory/); + assert.match(service, /Ending an operator relation never moves, rewrites or blocks existing Inventory/); + assert.match(service, /operatorSnapshotPreserved:true/); assert.match(service, /retainedCompatibilitySnapshotCount: before\.assignedAssetCount/); assert.doesNotMatch(service, /AREA_COMPANY_RELATION_IN_USE/); + assert.doesNotMatch(service, /UPDATE assets asset[\s\S]*SET operator_company_id=/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_CREATED/); assert.match(service, /ASSET_AREA_COMPANY_RELATION_ENDED/); }); From 4f1f26ad93489c088efd0bb5d20e55d8d278e0d4 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:12:43 -0300 Subject: [PATCH 07/26] test(f6.1): lock operational context invariants --- ...-1-operational-context-consistency.test.ts | 48 +++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100644 api-v3/test/unit/f6-1-operational-context-consistency.test.ts diff --git a/api-v3/test/unit/f6-1-operational-context-consistency.test.ts b/api-v3/test/unit/f6-1-operational-context-consistency.test.ts new file mode 100644 index 0000000..50eb34d --- /dev/null +++ b/api-v3/test/unit/f6-1-operational-context-consistency.test.ts @@ -0,0 +1,48 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +function source(path: string): string { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +test('F6.1 preserves Inventory operator snapshots and changes operator only through Area relations', () => { + const relations = source('src/asset-master/asset-operational-relations.service.ts'); + const migration = source('src/database/migrations/1790087250000-f5-operational-context-compatibility.ts'); + + assert.match(migration, /Runtime ownership and search MUST NOT depend on it/); + assert.match(migration, /La Empresa se cambia en la relación temporal del Área, no en el Inventario/); + assert.match(relations, /operatorSnapshotPreserved:true/); + assert.doesNotMatch(relations, /UPDATE assets asset[\s\S]*SET operator_company_id=/); +}); + +test('F6.1 blocks operator mutation through generic Inventory edit DTOs', () => { + const updateDto = source('src/asset-master/dto/update-asset.dto.ts'); + const contextDto = source('src/asset-master/dto/change-asset-context.dto.ts'); + + for (const dto of [updateDto, contextDto]) { + assert.match(dto, /@IsEmpty/); + assert.match(dto, /La Operadora se administra en la relación temporal del Área, no en el Inventario/); + } +}); + +test('F6.1 resolves temporal Inventory operator from Area relation history', () => { + const temporal = source('src/asset-master/asset-temporal.service.ts'); + + assert.match(temporal, /FROM area_company_relations relation/); + assert.match(temporal, /relation\.valid_from <= \$2/); + assert.match(temporal, /relation\.valid_until IS NULL OR relation\.valid_until > \$2/); + assert.match(temporal, /operator_company\.id=operator_relation\.company_id/); + assert.doesNotMatch(temporal, /operator_company\.id=history\.operator_company_id/); +}); + +test('F6.1 verification planning uses Area relation truth instead of Inventory operator snapshot', () => { + const verification = source('src/inspection-verifications/inspection-verifications.service.ts'); + + assert.match(verification, /FROM area_company_relations relation/); + assert.match(verification, /relation\.valid_from <= \$2::timestamptz/); + assert.match(verification, /VERIFICATION_OPERATOR_NOT_ACTIVE/); + assert.match(verification, /operatorSource: 'area_company_relations'/); + assert.doesNotMatch(verification, /company\.id = asset\.operator_company_id/); +}); From fc978ad74eee08b2745486da3d93be59266a978c Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:12:55 -0300 Subject: [PATCH 08/26] ci(f6.1): run Android on operational API contract changes --- .github/workflows/android.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 610cab4..aaa6c24 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -19,8 +19,11 @@ on: paths: - 'android-app/**' - 'api-v3/src/auth/**' + - 'api-v3/src/asset-master/**' - 'api-v3/src/inspection-visits/**' - 'api-v3/src/inspection-acts/**' + - 'api-v3/src/inspection-findings/**' + - 'api-v3/src/inspection-verifications/**' - '.github/workflows/android.yml' workflow_dispatch: @@ -66,4 +69,4 @@ jobs: name: DH-Inspeccion-F6.1-0.15.0-debug path: android-app/app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error - retention-days: 30 \ No newline at end of file + retention-days: 30 From 62f612dd247d11e170fc1c5141e325afe34bb940 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:13:32 -0300 Subject: [PATCH 09/26] ci(f6.1): require real Nest startup and health check --- .github/workflows/ci.yml | 32 ++++++++++++++++++++++++++++++-- 1 file changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ae004e6..d7e4bb6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -69,7 +69,7 @@ jobs: grep -Fq -- '$STAGE/android-app:/android-app:ro' scripts/deploy-github.sh - name: Validate Compose run: docker compose --env-file .env.example config >/dev/null - - name: Rehearse migrations on clean PostGIS + - name: Rehearse migrations and real API startup on clean PostGIS run: | set -Eeuo pipefail cleanup() { @@ -216,7 +216,35 @@ jobs: exit 1 } rm -f "$rerun_log" - - name: VPS-equivalent isolated API preflight + + # A build-only preflight cannot catch Nest dependency-injection or + # runtime configuration failures. Start the production API image against + # the migrated database and require the public health endpoint to answer. + export JWT_ACCESS_SECRET='CI_ACCESS_SECRET_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa' + export REFRESH_TOKEN_PEPPER='CI_REFRESH_PEPPER_bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb' + export SMTP_SETTINGS_MASTER_KEY='' + docker compose --env-file .env.example build api + docker compose --env-file .env.example up -d api + + api_ready=0 + for _ in $(seq 1 30); do + if curl -fsS http://127.0.0.1:3101/api/v3/health >/tmp/dhv2-health.json 2>/dev/null; then + api_ready=1 + break + fi + sleep 2 + done + if [ "$api_ready" -ne 1 ]; then + echo 'ERROR: production API image did not become healthy.' >&2 + docker compose --env-file .env.example logs --no-color api >&2 || true + exit 1 + fi + grep -Fq '"status":"ok"' /tmp/dhv2-health.json || { + echo 'ERROR: /api/v3/health did not report status ok.' >&2 + cat /tmp/dhv2-health.json >&2 + exit 1 + } + - name: Isolated builder test preflight run: | set -Eeuo pipefail image="dhv2-api:ci-vps-preflight-${GITHUB_SHA::12}" From 20ecbb2872ca4da5f112d40a12b1f8b2b20836ed Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:15:07 -0300 Subject: [PATCH 10/26] fix(f6.1): keep finding worklist on historical visit context --- .../inspection-finding-worklist.service.ts | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts b/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts index 42df273..62ba98f 100644 --- a/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts +++ b/api-v3/src/inspection-findings/inspection-finding-worklist.service.ts @@ -153,13 +153,16 @@ export class InspectionFindingWorklistService { workflowFilters.push(`finding.status = 'OPEN'`); } + // Hallazgo, Acta e Inspección forman un hecho histórico. Empresa y Área se + // leen del contexto congelado de la Inspección que originó el Hallazgo, no + // del snapshot de compatibilidad del Inventario. const joins = ` INNER JOIN inspection_acts act ON act.id = finding.act_id INNER JOIN inspection_visits visit ON visit.id = act.visit_id INNER JOIN assets asset ON asset.id = finding.asset_id INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id - LEFT JOIN assets company ON company.id = asset.operator_company_id - LEFT JOIN assets area ON area.id = asset.operational_area_id + LEFT JOIN assets company ON company.id = visit.operator_company_id + LEFT JOIN assets area ON area.id = visit.operational_area_id `; const filters = [...contextFilters, ...workflowFilters]; const where = filters.length ? `WHERE ${filters.join(' AND ')}` : ''; From b713be4d154b90ae1352d323930e30cd3d714f6d Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:16:45 -0300 Subject: [PATCH 11/26] fix(f6.1): keep reports on frozen inspection context --- .../inspection-reports.service.ts | 32 ++++++++----------- 1 file changed, 13 insertions(+), 19 deletions(-) diff --git a/api-v3/src/inspection-reports/inspection-reports.service.ts b/api-v3/src/inspection-reports/inspection-reports.service.ts index f4531f6..bdcf797 100644 --- a/api-v3/src/inspection-reports/inspection-reports.service.ts +++ b/api-v3/src/inspection-reports/inspection-reports.service.ts @@ -408,30 +408,24 @@ export class InspectionReportsService { } private contextFilter(column: 'operator_company_id' | 'operational_area_id', parameter: string): string { - return `EXISTS ( - SELECT 1 - FROM inspection_act_assets context_filter_link - INNER JOIN assets context_filter_asset ON context_filter_asset.id = context_filter_link.asset_id - WHERE context_filter_link.act_id = act.id - AND context_filter_link.included = true - AND context_filter_asset.${column} = ${parameter}::uuid - )`; + // Reports are historical documents. Their Area/Company filters must use the + // parent Inspection snapshot, never the mutable/current Inventory context. + return `visit.${column} = ${parameter}::uuid`; } private contextSelect(): string { return ` SELECT - COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id',company.id,'code',company.code,'name',company.name - )) FILTER (WHERE company.id IS NOT NULL),'[]'::jsonb) AS companies, - COALESCE(JSONB_AGG(DISTINCT JSONB_BUILD_OBJECT( - 'id',area.id,'code',area.code,'name',area.name - )) FILTER (WHERE area.id IS NOT NULL),'[]'::jsonb) AS areas - FROM inspection_act_assets context_link - INNER JOIN assets context_asset ON context_asset.id=context_link.asset_id - LEFT JOIN assets company ON company.id=context_asset.operator_company_id - LEFT JOIN assets area ON area.id=context_asset.operational_area_id - WHERE context_link.act_id=act.id AND context_link.included=true + CASE WHEN company.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( + JSONB_BUILD_OBJECT('id',company.id,'code',company.code,'name',company.name) + ) END AS companies, + CASE WHEN area.id IS NULL THEN '[]'::jsonb ELSE JSONB_BUILD_ARRAY( + JSONB_BUILD_OBJECT('id',area.id,'code',area.code,'name',area.name) + ) END AS areas + FROM inspection_visits context_visit + LEFT JOIN assets company ON company.id=context_visit.operator_company_id + LEFT JOIN assets area ON area.id=context_visit.operational_area_id + WHERE context_visit.id=act.visit_id `; } From ca1fddc834c205f3905631cc22be6d96c8e4611c Mon Sep 17 00:00:00 2001 From: enlineawork Date: Wed, 9 Sep 2026 15:18:28 -0300 Subject: [PATCH 12/26] fix(f6.1): separate physical context from area operator in web --- .../assets/AssetContextHistoryPanel.tsx | 41 +++++-------------- 1 file changed, 10 insertions(+), 31 deletions(-) diff --git a/web-v2/src/features/assets/AssetContextHistoryPanel.tsx b/web-v2/src/features/assets/AssetContextHistoryPanel.tsx index 5cce57f..bd559ce 100644 --- a/web-v2/src/features/assets/AssetContextHistoryPanel.tsx +++ b/web-v2/src/features/assets/AssetContextHistoryPanel.tsx @@ -7,7 +7,6 @@ import { changeAssetContext, listAssetContextHistory, listAssetParentOptions, - listCompaniesForArea, listOperationalAreas, } from '../../lib/api'; import type { @@ -29,7 +28,6 @@ function contextLabel(item: AssetContextHistoryItem) { const parts = [ item.parent ? `Padre: ${item.parent.name}` : 'Sin padre', item.operationalArea ? `Área: ${item.operationalArea.name}` : null, - item.operatorCompany ? `Operadora: ${item.operatorCompany.name}` : null, ].filter(Boolean); return parts.join(' · '); } @@ -55,9 +53,7 @@ export function AssetContextHistoryPanel({ const [parentSearch, setParentSearch] = useState(''); const [parents, setParents] = useState([]); const [operationalAreaId, setOperationalAreaId] = useState(asset.operationalArea?.id ?? ''); - const [operatorCompanyId, setOperatorCompanyId] = useState(asset.operatorCompany?.id ?? ''); const [areas, setAreas] = useState([]); - const [companies, setCompanies] = useState([]); const [effectiveAt, setEffectiveAt] = useState(localDateTimeNow()); const [reason, setReason] = useState(''); @@ -94,29 +90,14 @@ export function AssetContextHistoryPanel({ setAreas(items); if (operationalAreaId && !items.some((item) => item.id === operationalAreaId)) { setOperationalAreaId(''); - setOperatorCompanyId(''); } }) .catch((requestError) => setError(errorMessage(requestError))); }, [editing, genericContext, parentId]); - useEffect(() => { - if (!editing || !genericContext || !operationalAreaId) { - setCompanies([]); - return; - } - listCompaniesForArea(operationalAreaId) - .then((items) => { - setCompanies(items); - if (operatorCompanyId && !items.some((item) => item.id === operatorCompanyId)) setOperatorCompanyId(''); - }) - .catch((requestError) => setError(errorMessage(requestError))); - }, [editing, genericContext, operationalAreaId]); - const beginEdit = () => { setParentId(asset.parent?.id ?? ''); setOperationalAreaId(asset.operationalArea?.id ?? ''); - setOperatorCompanyId(asset.operatorCompany?.id ?? ''); setEffectiveAt(localDateTimeNow()); setReason(''); setError(''); @@ -133,13 +114,12 @@ export function AssetContextHistoryPanel({ const saved = await changeAssetContext(asset.id, { parentId: parentId || null, operationalAreaId: genericContext ? operationalAreaId || null : asset.operationalArea?.id ?? null, - operatorCompanyId: genericContext ? operatorCompanyId || null : asset.operatorCompany?.id ?? null, effectiveAt: effectiveAt ? new Date(effectiveAt).toISOString() : undefined, reason, }); onChanged(saved); setEditing(false); - setSuccess('Contexto actualizado. La asignación anterior quedó preservada en el historial.'); + setSuccess('Contexto físico actualizado. La asignación anterior quedó preservada en el historial.'); loadHistory(); } catch (requestError) { setError(errorMessage(requestError)); @@ -150,30 +130,29 @@ export function AssetContextHistoryPanel({ return
-
CONTEXTO TEMPORAL

Jerarquía, Área y Operadora

- {canManage && !editing && } +
CONTEXTO TEMPORAL

Jerarquía y Área

+ {canManage && !editing && }
-

Los cambios no reemplazan la historia. Cada asignación conserva desde cuándo fue válida y qué relación la reemplazó.

+

Los cambios físicos no reemplazan la historia. La Operadora se administra por separado en las relaciones temporales del Área.

{error && {error}} {success && {success}} - {current &&

Contexto vigente. {contextLabel(current)}

} + {current &&

Contexto físico vigente. {contextLabel(current)}

} {editing &&
-

Cambiar contexto vigente

Indicá el nuevo lugar dentro del Inventario y el motivo. La relación anterior se cierra automáticamente.

+

Cambiar contexto físico vigente

Indicá la nueva ubicación dentro del Inventario y el motivo. La relación física anterior se cierra automáticamente.

Registro padre setParentSearch(event.target.value)} placeholder="Buscar registro padre…" /> { setParentId(event.target.value); setParentSearch(''); }} required={!type.canBeRoot}>{parents.map((parent) => )}
{genericContext &&
- - +
} -