From 88e7e8de65c059f5002bb13653eaaaac12def5f2 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:32:12 -0300 Subject: [PATCH 01/11] feat(api): scope preventive candidates to inspection yacimiento --- ...nspection-preventive-candidates.service.ts | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) create mode 100644 api-v3/src/inspection-visits/inspection-preventive-candidates.service.ts diff --git a/api-v3/src/inspection-visits/inspection-preventive-candidates.service.ts b/api-v3/src/inspection-visits/inspection-preventive-candidates.service.ts new file mode 100644 index 0000000..ecbbdc7 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-preventive-candidates.service.ts @@ -0,0 +1,77 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { DataSource } from 'typeorm'; + +export interface InspectionPreventiveCandidate { + id: string; + code: string; + name: string; + typeName: string; +} + +@Injectable() +export class InspectionPreventiveCandidatesService { + constructor(private readonly dataSource: DataSource) {} + + async list(visitId: string, search?: string): Promise<{ data: InspectionPreventiveCandidate[] }> { + const [context] = (await this.dataSource.query(` + SELECT + visit.operational_area_id AS "operationalAreaId", + COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId" + FROM inspection_visits visit + WHERE visit.id = $1::uuid + `, [visitId])) as Array<{ + operationalAreaId: string | null; + scopeAssetId: string | null; + }>; + + if (!context) { + throw new NotFoundException({ + code: 'INSPECTION_VISIT_NOT_FOUND', + message: 'Visita de inspección no encontrada', + }); + } + + if (!context.operationalAreaId || !context.scopeAssetId) return { data: [] }; + + const term = search?.trim().slice(0, 200) ?? ''; + const rows = (await this.dataSource.query(` + WITH RECURSIVE scope_tree AS ( + SELECT root.id + FROM assets root + WHERE root.id = $2::uuid + UNION ALL + SELECT child.id + FROM assets child + INNER JOIN scope_tree parent_scope ON parent_scope.id = child.parent_id + ) + SELECT + asset.id, + asset.code, + asset.name, + asset_type.name AS "typeName" + FROM scope_tree + INNER JOIN assets asset ON asset.id = scope_tree.id + INNER JOIN asset_types asset_type ON asset_type.id = asset.asset_type_id + WHERE asset.id <> $2::uuid + AND asset.operational_area_id = $3::uuid + AND asset.information_status <> 'INACTIVE' + AND lower(asset_type.code) IN ('instalacion', 'subinstalacion') + AND NOT EXISTS ( + SELECT 1 + FROM inspection_visit_assets linked + WHERE linked.visit_id = $1::uuid + AND linked.asset_id = asset.id + ) + AND ( + $4::text = '' + OR asset.code ILIKE '%' || $4::text || '%' + OR asset.name ILIKE '%' || $4::text || '%' + OR COALESCE(asset.common_name, '') ILIKE '%' || $4::text || '%' + ) + ORDER BY asset_type.name, asset.name, asset.code + LIMIT 100 + `, [visitId, context.scopeAssetId, context.operationalAreaId, term])) as InspectionPreventiveCandidate[]; + + return { data: rows }; + } +} From 82b587ea6ac5a8798c4c8878360ad279a69df5bc Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:32:18 -0300 Subject: [PATCH 02/11] feat(api): expose scoped preventive candidates --- ...spection-preventive-candidates.controller.ts | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 api-v3/src/inspection-visits/inspection-preventive-candidates.controller.ts diff --git a/api-v3/src/inspection-visits/inspection-preventive-candidates.controller.ts b/api-v3/src/inspection-visits/inspection-preventive-candidates.controller.ts new file mode 100644 index 0000000..6322ce3 --- /dev/null +++ b/api-v3/src/inspection-visits/inspection-preventive-candidates.controller.ts @@ -0,0 +1,17 @@ +import { Controller, Get, Param, ParseUUIDPipe, Query } from '@nestjs/common'; +import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; +import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service'; + +@Controller('inspection-visits') +export class InspectionPreventiveCandidatesController { + constructor(private readonly candidates: InspectionPreventiveCandidatesService) {} + + @Get(':id/preventive-candidates') + @RequirePermissions('inspections.read') + list( + @Param('id', new ParseUUIDPipe({ version: '4' })) id: string, + @Query('search') search?: string, + ) { + return this.candidates.list(id, search); + } +} From 9e6d9e45cb10590c0cf95f56371cd69afaa0128c Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:32:29 -0300 Subject: [PATCH 03/11] feat(api): register preventive candidate endpoint --- .../src/inspection-visits/inspection-visits.module.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/api-v3/src/inspection-visits/inspection-visits.module.ts b/api-v3/src/inspection-visits/inspection-visits.module.ts index 7554f04..164404f 100644 --- a/api-v3/src/inspection-visits/inspection-visits.module.ts +++ b/api-v3/src/inspection-visits/inspection-visits.module.ts @@ -10,17 +10,25 @@ import { FieldInventoryController } from './field-inventory.controller'; import { FieldInventoryService } from './field-inventory.service'; import { InspectionPlanningCreateService } from './inspection-planning-create.service'; import { InspectionPlanningHierarchyService } from './inspection-planning-hierarchy.service'; +import { InspectionPreventiveCandidatesController } from './inspection-preventive-candidates.controller'; +import { InspectionPreventiveCandidatesService } from './inspection-preventive-candidates.service'; import { InspectionVisitLifecycleService } from './inspection-visit-lifecycle.service'; import { InspectionVisitsController } from './inspection-visits.controller'; import { InspectionVisitsService } from './inspection-visits.service'; @Module({ imports: [AuditModule, AssetMasterModule, InspectionFindingsModule], - controllers: [InspectionVisitsController, FieldInventoryController, FieldFindingsController], + controllers: [ + InspectionVisitsController, + InspectionPreventiveCandidatesController, + FieldInventoryController, + FieldFindingsController, + ], providers: [ { provide: InspectionVisitsService, useClass: F4InspectionVisitsService }, InspectionPlanningCreateService, InspectionPlanningHierarchyService, + InspectionPreventiveCandidatesService, InspectionVisitLifecycleService, FieldInventoryService, F3FieldInventoryStructureService, From b7baea652be041877205889e299d37cb6d2ecfc9 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:32:39 -0300 Subject: [PATCH 04/11] fix(api): freeze inspection context after creation --- .../inspection-visits/dto/update-inspection-visit.dto.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/api-v3/src/inspection-visits/dto/update-inspection-visit.dto.ts b/api-v3/src/inspection-visits/dto/update-inspection-visit.dto.ts index f5b5685..eaca0ac 100644 --- a/api-v3/src/inspection-visits/dto/update-inspection-visit.dto.ts +++ b/api-v3/src/inspection-visits/dto/update-inspection-visit.dto.ts @@ -1,9 +1,9 @@ import { Transform } from 'class-transformer'; import { + IsEmpty, IsISO8601, IsOptional, IsString, - IsUUID, MaxLength, } from 'class-validator'; @@ -17,15 +17,15 @@ export class UpdateInspectionVisitDto { objective?: string | null; @IsOptional() - @IsUUID('4') + @IsEmpty({ message: 'El Yacimiento se fija al crear la Inspección y no puede modificarse' }) scopeAssetId?: string | null; @IsOptional() - @IsUUID('4') + @IsEmpty({ message: 'El Área se fija al crear la Inspección y no puede modificarse' }) operationalAreaId?: string | null; @IsOptional() - @IsUUID('4') + @IsEmpty({ message: 'La Operadora se fija al crear la Inspección y no puede modificarse' }) operatorCompanyId?: string | null; @IsOptional() From 53c8f2b4abd1fd0f62a95a5b57ea6a7f57c205b5 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:32:49 -0300 Subject: [PATCH 05/11] feat(db): enforce immutable inspection context --- ...98200000-f6-2-freeze-inspection-context.ts | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) create mode 100644 api-v3/src/database/migrations/1790098200000-f6-2-freeze-inspection-context.ts diff --git a/api-v3/src/database/migrations/1790098200000-f6-2-freeze-inspection-context.ts b/api-v3/src/database/migrations/1790098200000-f6-2-freeze-inspection-context.ts new file mode 100644 index 0000000..286a5f6 --- /dev/null +++ b/api-v3/src/database/migrations/1790098200000-f6-2-freeze-inspection-context.ts @@ -0,0 +1,39 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class F62FreezeInspectionContext1790098200000 implements MigrationInterface { + name = 'F62FreezeInspectionContext1790098200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE OR REPLACE FUNCTION prevent_inspection_context_mutation() + RETURNS trigger LANGUAGE plpgsql AS $$ + BEGIN + IF NEW.operational_area_id IS DISTINCT FROM OLD.operational_area_id + OR NEW.scope_asset_id IS DISTINCT FROM OLD.scope_asset_id + OR NEW.operator_company_id IS DISTINCT FROM OLD.operator_company_id THEN + RAISE EXCEPTION USING + ERRCODE='23514', + MESSAGE='El Área, Yacimiento y Operadora de una Inspección quedan fijos desde su creación'; + END IF; + RETURN NEW; + END $$ + `); + + await queryRunner.query(` + DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits + `); + await queryRunner.query(` + CREATE TRIGGER trg_inspection_context_immutable + BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id + ON inspection_visits + FOR EACH ROW EXECUTE FUNCTION prevent_inspection_context_mutation() + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + DROP TRIGGER IF EXISTS trg_inspection_context_immutable ON inspection_visits + `); + await queryRunner.query('DROP FUNCTION IF EXISTS prevent_inspection_context_mutation()'); + } +} From 74b91d0c8a912c1e7aeb7ef9b7e46ef9ee9b94f5 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:33:01 -0300 Subject: [PATCH 06/11] feat(web): load preventive inventory from frozen scope --- .../inspections/preventiveCandidatesApi.ts | 43 +++++++++++++++++++ 1 file changed, 43 insertions(+) create mode 100644 web-v2/src/features/inspections/preventiveCandidatesApi.ts diff --git a/web-v2/src/features/inspections/preventiveCandidatesApi.ts b/web-v2/src/features/inspections/preventiveCandidatesApi.ts new file mode 100644 index 0000000..42257ba --- /dev/null +++ b/web-v2/src/features/inspections/preventiveCandidatesApi.ts @@ -0,0 +1,43 @@ +export interface InspectionPreventiveCandidate { + id: string; + code: string; + name: string; + typeName: string; +} + +interface PreventiveCandidateResponse { + data: InspectionPreventiveCandidate[]; +} + +export async function listInspectionPreventiveCandidates( + visitId: string, + search = '', +): Promise { + const query = new URLSearchParams(); + const term = search.trim(); + if (term) query.set('search', term); + + const response = await fetch( + `/api/v3/inspection-visits/${visitId}/preventive-candidates${query.size ? `?${query}` : ''}`, + { + method: 'GET', + credentials: 'same-origin', + headers: { Accept: 'application/json' }, + }, + ); + + if (!response.ok) { + let message = 'No se pudo cargar el Inventario disponible para esta Inspección'; + try { + const payload = await response.json() as { message?: string | string[] }; + if (Array.isArray(payload.message)) message = payload.message.join('. '); + else if (payload.message) message = payload.message; + } catch { + // Conserva el mensaje funcional cuando la respuesta no sea JSON. + } + throw new Error(message); + } + + const payload = await response.json() as PreventiveCandidateResponse; + return payload.data ?? []; +} From cb002c60f99731137fc73168ffdf9a3e68d9433c Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:34:09 -0300 Subject: [PATCH 07/11] fix(web): freeze inspection context and scope preventive selector --- .../src/pages/InspectionVisitEditorF4Page.tsx | 50 +++++++++---------- 1 file changed, 25 insertions(+), 25 deletions(-) diff --git a/web-v2/src/pages/InspectionVisitEditorF4Page.tsx b/web-v2/src/pages/InspectionVisitEditorF4Page.tsx index 499dc51..78ba97e 100644 --- a/web-v2/src/pages/InspectionVisitEditorF4Page.tsx +++ b/web-v2/src/pages/InspectionVisitEditorF4Page.tsx @@ -11,13 +11,18 @@ import { inspectionStatusClass, inspectionVisitStatusLabel, } from '../features/inspections/inspectionPresentation'; +import { + listInspectionPreventiveCandidates, +} from '../features/inspections/preventiveCandidatesApi'; +import type { + InspectionPreventiveCandidate, +} from '../features/inspections/preventiveCandidatesApi'; import { createInspectionVisit, excludeInspectionVisitAsset, generateInspectionVisitChecklist, getInspectionVisit, includeInspectionVisitAsset, - listAssets, listInspectionAssignees, listInspectionPlanningAreas, listInspectionPlanningOperators, @@ -26,7 +31,6 @@ import { updateInspectionVisit, } from '../lib/api'; import type { - AssetListItem, InspectionPerson, InspectionPlanningContextAsset, InspectionVisit, @@ -100,7 +104,7 @@ export function InspectionVisitEditorF4Page() { const [areas, setAreas] = useState([]); const [operators, setOperators] = useState([]); const [assignees, setAssignees] = useState([]); - const [assets, setAssets] = useState([]); + const [assets, setAssets] = useState([]); const [assetSearch, setAssetSearch] = useState(''); const [newAssetId, setNewAssetId] = useState(''); const [leadInspectorId, setLeadInspectorId] = useState(''); @@ -136,9 +140,9 @@ export function InspectionVisitEditorF4Page() { }; useEffect(() => { - listInspectionPlanningAreas().then(setAreas).catch(() => undefined); + if (isNew) listInspectionPlanningAreas().then(setAreas).catch(() => undefined); if (canAssign) listInspectionAssignees().then(setAssignees).catch(() => undefined); - }, [canAssign]); + }, [canAssign, isNew]); useEffect(() => { if (!isNew || form.operationalAreaId || !context.areaId) return; @@ -158,29 +162,27 @@ export function InspectionVisitEditorF4Page() { }, [id]); useEffect(() => { - if (!form.operationalAreaId) { + if (!isNew || !form.operationalAreaId) { setOperators([]); return; } listInspectionPlanningOperators(form.operationalAreaId) .then(setOperators) .catch(() => setOperators([])); - }, [form.operationalAreaId]); + }, [form.operationalAreaId, isNew]); useEffect(() => { - if (!form.operationalAreaId || !form.operatorCompanyId) { + if (!id || !visit) { setAssets([]); return; } const timer = window.setTimeout(() => { - listAssets({ - pageSize: 100, - search: assetSearch.trim(), - operationalAreaId: form.operationalAreaId, - }).then((response) => setAssets(response.data)).catch(() => undefined); + listInspectionPreventiveCandidates(id, assetSearch) + .then(setAssets) + .catch((requestError) => setError(errorMessage(requestError))); }, 220); return () => window.clearTimeout(timer); - }, [assetSearch, form.operationalAreaId, form.operatorCompanyId]); + }, [id, assetSearch, visit?.updatedAt]); const planningEditable = !visit || visit.status === 'DRAFT' || visit.status === 'PLANNED'; const activeAssetIds = useMemo(() => new Set(visit?.assets.map((asset) => asset.id) ?? []), [visit]); @@ -209,13 +211,10 @@ export function InspectionVisitEditorF4Page() { } else if (id) { applyVisit(await updateInspectionVisit(id, { objective: form.objective.trim() || null, - scopeAssetId: visit?.scopeAsset?.id ?? null, - operationalAreaId: form.operationalAreaId || null, - operatorCompanyId: form.operatorCompanyId || null, plannedStartAt, instructions: form.instructions.trim() || null, })); - setSuccess('Planificación actualizada. Regenerá el checklist si cambió contexto o fecha.'); + setSuccess('Planificación actualizada. Si cambió la fecha, regenerá el checklist.'); } } catch (requestError) { setError(errorMessage(requestError)); @@ -240,6 +239,7 @@ export function InspectionVisitEditorF4Page() { setBusy(true); setError(''); setSuccess(''); try { applyVisit(await replaceInspectionVisitAssets(id, [...activeAssetIds, newAssetId]), false); + setAssets((current) => current.filter((asset) => asset.id !== newAssetId)); setNewAssetId(''); setSuccess('Registro agregado como preventivo.'); } catch (requestError) { setError(errorMessage(requestError)); } @@ -340,12 +340,12 @@ export function InspectionVisitEditorF4Page() { {error && {error}}{success && {success}}
-
{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}

Contexto y fecha de inicio

No existe título independiente ni fecha de fin planificada. El cierre real se registra al terminar el trabajo de campo.

{visit && Actualizado {formatDate(visit.updatedAt)}}
+
{isNew ? 'CREACIÓN RÁPIDA' : 'PLANIFICACIÓN'}

Contexto y fecha de inicio

El Área, Yacimiento y Operadora quedan fijados al crear la Inspección. La fecha puede reprogramarse antes del trabajo de campo.

{visit && Actualizado {formatDate(visit.updatedAt)}}
{visit &&
Identificador institucional{visit.code}
}
- - {!isNew && } - + {isNew ? : } + {!isNew && } + {isNew ? : } {isNew && }
@@ -354,15 +354,15 @@ export function InspectionVisitEditorF4Page() {
{visit &&
-
CHECKLIST TÉCNICO

Antecedentes y controles

Se construye por Área, Operadora y fecha. Las respuestas administrativas de empresa no modifican este checklist.

{canManage && planningEditable && }
- {visit.checklist.stale && El contexto o la fecha cambió. Regenerá el checklist antes de planificar.} +
CHECKLIST TÉCNICO

Antecedentes y controles

Se construye para el Área, Yacimiento y Operadora fijados en la Inspección, tomando la fecha planificada. Las respuestas administrativas de empresa no modifican este checklist.

{canManage && planningEditable && }
+ {visit.checklist.stale && La fecha cambió. Regenerá el checklist antes de planificar.}
{visit.checklist.verificationOverdue}controles vencidos
{visit.checklist.upcomingControls}próximos 30 días
{visit.checklist.antecedents}antecedentes
{visit.checklist.actionableAssets}registros sugeridos
{visit.checklist.items.length === 0 ? :
{visit.checklist.items.map((item) => )}
TipoHallazgoInventarioFechaGravedad
{checklistLabel(item.itemKind)}{item.findingTitle}{item.findingCode}{item.asset.name}{item.asset.code} · {item.asset.typeName}{formatDateOnly(item.referenceOn)}{item.severity ?? '—'}
}
} {visit && visit.verificationFindings.length > 0 &&
VERIFICACIÓN

Hallazgos a controlar

{visit.verificationFindings.length}
{visit.verificationFindings.map((finding) =>
{finding.title}{finding.code} · {finding.assetName} · objetivo {formatDateOnly(finding.targetControlOn ?? finding.nextControlOn)}{finding.resultNotes && {finding.resultNotes}}
{finding.outcome === 'RESOLVED' ? 'Solucionado' : finding.outcome === 'NOT_RESOLVED' ? 'No solucionado' : finding.outcome === 'REQUIRES_NEW_DATE' ? 'Reprogramar' : 'Pendiente'})}
} - {visit && planningEditable && canManage &&
PREVENTIVO

Agregar Inventario sin pendiente previo

} + {visit && planningEditable && canManage &&
PREVENTIVO

Agregar Inventario sin pendiente previo

Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento fijado para esta Inspección.

} {visit &&
{visit.assets.length} incluidos{visit.checklist.excludedAssets} excluidos con trazabilidad
{visit.planningAssets.length === 0 ? :
{visit.planningAssets.map((asset) => )}
InventarioOrigenEstadoMotivo
{asset.name}{asset.code} · {asset.typeName}{sourceLabel(asset.planningSource)}{asset.included ? 'Incluido' : 'Excluido'}{asset.exclusionReason ?? '—'}{canManage && planningEditable && (asset.included ? : )}
}
} From ff7706497d4ef4f374e04d5b40b69bc59172ac62 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:34:28 -0300 Subject: [PATCH 08/11] test(api): align F6.1 with immutable inspection context --- .../f6-1-inspection-yacimiento-scope-review.test.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/api-v3/test/unit/f6-1-inspection-yacimiento-scope-review.test.ts b/api-v3/test/unit/f6-1-inspection-yacimiento-scope-review.test.ts index e4ea3e2..afc30c5 100644 --- a/api-v3/test/unit/f6-1-inspection-yacimiento-scope-review.test.ts +++ b/api-v3/test/unit/f6-1-inspection-yacimiento-scope-review.test.ts @@ -62,11 +62,17 @@ test('F6.1 operator choices are resolved at the planned timestamp', () => { assert.match(createPage, /\[areaId, plannedStartAt\]/); }); -test('F6.1 WEB keeps Area and Yacimiento as separate concepts when editing', () => { +test('F6.1 WEB keeps Area and Yacimiento as separate concepts and freezes context after creation', () => { const source = web('src/pages/InspectionVisitEditorF4Page.tsx'); assert.doesNotMatch(source, /Área \/ Yacimiento<\/span>/); assert.match(source, /Área<\/span>/); assert.match(source, /Yacimiento<\/span>/); - assert.match(source, /scopeAssetId: visit\?\.scopeAsset\?\.id \?\? null/); + assert.match(source, /Operadora<\/span>/); + assert.match(source, /visit\.operationalArea\.name[\s\S]{0,160}readOnly/); + assert.match(source, /visit\.scopeAsset\.name[\s\S]{0,160}readOnly/); + assert.match(source, /visit\.operatorCompany\.name[\s\S]{0,160}readOnly/); + assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}scopeAssetId:/); + assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operationalAreaId:/); + assert.doesNotMatch(source, /updateInspectionVisit\(id, \{[\s\S]{0,300}operatorCompanyId:/); }); From 5b1593ed7808a77bb787777fdd443af753c63205 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:34:42 -0300 Subject: [PATCH 09/11] test(api): reject inspection context changes after creation --- .../test/unit/inspection-visits-dto.test.ts | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/api-v3/test/unit/inspection-visits-dto.test.ts b/api-v3/test/unit/inspection-visits-dto.test.ts index 98f979d..da91401 100644 --- a/api-v3/test/unit/inspection-visits-dto.test.ts +++ b/api-v3/test/unit/inspection-visits-dto.test.ts @@ -7,6 +7,7 @@ import { ChangeInspectionVisitStatusDto } from '../../src/inspection-visits/dto/ import { CreateInspectionVisitDto } from '../../src/inspection-visits/dto/create-inspection-visit.dto'; import { ReplaceInspectionVisitAssetsDto } from '../../src/inspection-visits/dto/replace-inspection-visit-assets.dto'; import { ReplaceInspectionVisitTeamDto } from '../../src/inspection-visits/dto/replace-inspection-visit-team.dto'; +import { UpdateInspectionVisitDto } from '../../src/inspection-visits/dto/update-inspection-visit.dto'; const AREA_ID = '16e54e65-60cf-4739-b0d1-ccdd904fbfd5'; const COMPANY_ID = 'ce96211d-ac15-4421-8868-4185323aff61'; @@ -25,6 +26,25 @@ test('visit DTO accepts only the minimum planning data required for quick creati assert.equal(dto.leadInspectorUserId, USER_ID); }); +test('visit update DTO keeps Area, Yacimiento and Operadora immutable after creation', async () => { + const contextAttempt = plainToInstance(UpdateInspectionVisitDto, { + operationalAreaId: AREA_ID, + scopeAssetId: AREA_ID, + operatorCompanyId: COMPANY_ID, + }); + const errors = await validate(contextAttempt); + assert.equal(errors.some((error) => error.property === 'operationalAreaId'), true); + assert.equal(errors.some((error) => error.property === 'scopeAssetId'), true); + assert.equal(errors.some((error) => error.property === 'operatorCompanyId'), true); + + const ordinaryUpdate = plainToInstance(UpdateInspectionVisitDto, { + objective: 'Control preventivo programado', + plannedStartAt: '2026-09-17T12:00:00.000Z', + instructions: 'Coordinar ingreso con la Operadora.', + }); + assert.deepEqual(await validate(ordinaryUpdate), []); +}); + test('asset and team DTOs reject duplicate references', async () => { const assets = Object.assign(new ReplaceInspectionVisitAssetsDto(), { assetIds: [AREA_ID, AREA_ID], From b23d7736c0b74755aac3517bf880a47427cc5f6c Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:34:55 -0300 Subject: [PATCH 10/11] test(api): cover immutable context and scoped preventive inventory --- ...f6-2-inspection-context-preventive.test.ts | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) create mode 100644 api-v3/test/unit/f6-2-inspection-context-preventive.test.ts diff --git a/api-v3/test/unit/f6-2-inspection-context-preventive.test.ts b/api-v3/test/unit/f6-2-inspection-context-preventive.test.ts new file mode 100644 index 0000000..4d26bfa --- /dev/null +++ b/api-v3/test/unit/f6-2-inspection-context-preventive.test.ts @@ -0,0 +1,40 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +const api = (path: string) => readFileSync(resolve(process.cwd(), path), 'utf8'); +const web = (path: string) => readFileSync(resolve(process.cwd(), '..', 'web-v2', path), 'utf8'); + +test('F6.2 protects the frozen Inspection context at database level', () => { + const migration = api('src/database/migrations/1790098200000-f6-2-freeze-inspection-context.ts'); + + assert.match(migration, /prevent_inspection_context_mutation/); + assert.match(migration, /NEW\.operational_area_id IS DISTINCT FROM OLD\.operational_area_id/); + assert.match(migration, /NEW\.scope_asset_id IS DISTINCT FROM OLD\.scope_asset_id/); + assert.match(migration, /NEW\.operator_company_id IS DISTINCT FROM OLD\.operator_company_id/); + assert.match(migration, /BEFORE UPDATE OF operational_area_id, scope_asset_id, operator_company_id/); +}); + +test('F6.2 preventive candidates are descendants of the frozen Yacimiento only', () => { + const service = api('src/inspection-visits/inspection-preventive-candidates.service.ts'); + const controller = api('src/inspection-visits/inspection-preventive-candidates.controller.ts'); + + assert.match(service, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/); + assert.match(service, /WITH RECURSIVE scope_tree AS/); + assert.match(service, /child\.parent_id/); + assert.match(service, /asset\.operational_area_id = \$3::uuid/); + assert.match(service, /lower\(asset_type\.code\) IN \('instalacion', 'subinstalacion'\)/); + assert.match(service, /NOT EXISTS \([\s\S]*inspection_visit_assets linked/); + assert.match(controller, /:id\/preventive-candidates/); +}); + +test('F6.2 WEB uses the server-scoped preventive selector instead of the mutable Area form', () => { + const page = web('src/pages/InspectionVisitEditorF4Page.tsx'); + const apiClient = web('src/features/inspections/preventiveCandidatesApi.ts'); + + assert.match(page, /listInspectionPreventiveCandidates\(id, assetSearch\)/); + assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,220}operationalAreaId: form\.operationalAreaId/); + assert.match(page, /Sólo se muestran Instalaciones y Subinstalaciones pertenecientes al Yacimiento/); + assert.match(apiClient, /inspection-visits\/\$\{visitId\}\/preventive-candidates/); +}); From 466fb29089d9b011e489f40b99439d1369565b74 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 21:38:16 -0300 Subject: [PATCH 11/11] test(api): align inventory search contract with preventive scope --- .../f6-1-operational-context-consistency.test.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) 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 index 15ce478..250edc0 100644 --- a/api-v3/test/unit/f6-1-operational-context-consistency.test.ts +++ b/api-v3/test/unit/f6-1-operational-context-consistency.test.ts @@ -75,12 +75,20 @@ test('F6.1 historical operational lists read Company and Area from the parent In assert.doesNotMatch(reports, /context_asset\.operator_company_id/); }); -test('F6.1 WEB Inventory searches use physical Area and never the operator snapshot filter', () => { +test('F6.1 WEB Inventory searches use physical scope and never the operator snapshot filter', () => { const inspectionEditor = source('../web-v2/src/pages/InspectionVisitEditorF4Page.tsx'); + const preventiveApi = source('../web-v2/src/features/inspections/preventiveCandidatesApi.ts'); + const preventiveService = source('src/inspection-visits/inspection-preventive-candidates.service.ts'); const fieldDiscoveries = source('../web-v2/src/pages/FieldDiscoveriesPage.tsx'); const inventoryMerge = source('../web-v2/src/lib/inventoryMergeApi.ts'); - for (const page of [inspectionEditor, fieldDiscoveries, inventoryMerge]) { + assert.match(inspectionEditor, /listInspectionPreventiveCandidates\(id, assetSearch\)/); + assert.match(preventiveApi, /inspection-visits\/\$\{visitId\}\/preventive-candidates/); + assert.match(preventiveService, /COALESCE\(visit\.scope_asset_id, visit\.operational_area_id\) AS "scopeAssetId"/); + assert.doesNotMatch(preventiveService, /asset\.operator_company_id/); + assert.doesNotMatch(inspectionEditor, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/); + + for (const page of [fieldDiscoveries, inventoryMerge]) { assert.match(page, /listAssets\(\{[\s\S]{0,300}operationalAreaId:/); assert.doesNotMatch(page, /listAssets\(\{[\s\S]{0,300}operatorCompanyId:/); }