From b1f0064b40ef602d65c9baf2d8a73f8418eaf59e Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Thu, 10 Sep 2026 18:45:42 +0000 Subject: [PATCH] fix(F6.1): enforce Yacimiento scope end to end --- .github/workflows/f6-1-address-review.yml | 184 ------------------ .../field-findings.service.ts | 17 +- .../field-inventory.service.ts | 36 ++-- .../inspection-planning-create.service.ts | 43 ++++ .../inspection-visits.controller.ts | 3 +- .../inspection-visits.service.ts | 56 +++--- .../pages/InspectionVisitCreateF61Page.tsx | 34 ++-- .../src/pages/InspectionVisitEditorF4Page.tsx | 4 +- 8 files changed, 129 insertions(+), 248 deletions(-) delete mode 100644 .github/workflows/f6-1-address-review.yml diff --git a/.github/workflows/f6-1-address-review.yml b/.github/workflows/f6-1-address-review.yml deleted file mode 100644 index 15b5230..0000000 --- a/.github/workflows/f6-1-address-review.yml +++ /dev/null @@ -1,184 +0,0 @@ -name: F6.1 address inspection review - -on: - push: - branches: - - 'hotfix/f6-1-inspection-planning-hierarchy' - paths: - - '.github/workflows/f6-1-address-review.yml' - -permissions: - contents: write - -jobs: - apply: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - with: - ref: hotfix/f6-1-inspection-planning-hierarchy - fetch-depth: 0 - - - name: Apply reviewed F6.1 scope fixes - run: | - python - <<'PY' - from pathlib import Path - - def replace_once(path, before, after, label): - p = Path(path) - text = p.read_text() - count = text.count(before) - if count != 1: - raise SystemExit(f'{label}: expected one match, got {count}') - p.write_text(text.replace(before, after)) - - # Controller: WEB operator choices are resolved at the planned timestamp. - replace_once( - 'api-v3/src/inspection-visits/inspection-visits.controller.ts', - ''' planningOperators(\n @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,\n ) {\n return this.visits.listPlanningOperators(areaId);\n }''', - ''' planningOperators(\n @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string,\n @Query('at') at?: string,\n ) {\n return this.planningHierarchy.operatorsForArea(areaId, at);\n }''', - 'controller planned operator lookup', - ) - - # WEB create: Yacimientos depend on Area; Operators depend on Area + planned date. - replace_once( - 'web-v2/src/pages/InspectionVisitCreateF61Page.tsx', - ''' useEffect(() => {\n setYacimientoId('');\n setOperatorId('');\n setYacimientos([]);\n setOperators([]);\n if (!areaId) return;\n Promise.all([\n requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`),\n requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators`),\n ]).then(([yacimientoResponse, operatorResponse]) => {\n setYacimientos(yacimientoResponse.data);\n setOperators(operatorResponse.data);\n if (operatorResponse.data.length === 1 && operatorResponse.data[0]) {\n setOperatorId(operatorResponse.data[0].id);\n }\n }).catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));\n }, [areaId]);''', - ''' useEffect(() => {\n setYacimientoId('');\n setOperatorId('');\n setYacimientos([]);\n setOperators([]);\n if (!areaId) return;\n requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/yacimientos`)\n .then((response) => setYacimientos(response.data))\n .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));\n }, [areaId]);\n\n useEffect(() => {\n setOperatorId('');\n setOperators([]);\n if (!areaId || !plannedStartAt) return;\n const parsedStart = new Date(plannedStartAt);\n if (Number.isNaN(parsedStart.getTime())) return;\n const at = encodeURIComponent(parsedStart.toISOString());\n requestJson<{ data: Option[] }>(`/inspection-visits/planning-context/areas/${areaId}/operators?at=${at}`)\n .then((response) => {\n setOperators(response.data);\n if (response.data.length === 1 && response.data[0]) setOperatorId(response.data[0].id);\n })\n .catch((cause) => setError(cause instanceof Error ? cause.message : String(cause)));\n }, [areaId, plannedStartAt]);''', - 'WEB planned operator effect', - ) - p = Path('web-v2/src/pages/InspectionVisitCreateF61Page.tsx') - text = p.read_text() - text = text.replace('disabled={!areaId || operators.length === 0}', 'disabled={!areaId || !plannedStartAt || operators.length === 0}') - text = text.replace("{operators.length === 0 && areaId ? 'Sin Operadora vigente' : 'Seleccionar Operadora…'}", "{operators.length === 0 && areaId && plannedStartAt ? 'Sin Operadora vigente para esa fecha' : 'Seleccionar Operadora…'}") - text = text.replace('{areaId && operators.length === 0 && ', '{areaId && plannedStartAt && operators.length === 0 && ') - p.write_text(text) - - # Field Inventory: scope is the selected Yacimiento (or Area for a legacy mobile visit). - p = Path('api-v3/src/inspection-visits/field-inventory.service.ts') - text = p.read_text() - text = text.replace(' areaId: string;\n areaCode: string;', ' areaId: string;\n scopeAssetId: string;\n areaCode: string;', 1) - text = text.replace('// Empresa is visit context, never physical ownership. Search by Area ancestry.\n const args: unknown[] = [context.areaId, visitId];', '// Empresa is visit context, never physical ownership. Search stays inside the frozen Yacimiento scope.\n const args: unknown[] = [context.scopeAssetId, visitId];', 1) - text = text.replace(''' `(\n asset.operational_area_id = $1::uuid\n OR EXISTS (\n WITH RECURSIVE ancestors AS (\n SELECT id,parent_id FROM assets WHERE id=asset.parent_id\n UNION ALL\n SELECT parent.id,parent.parent_id\n FROM assets parent JOIN ancestors child ON parent.id=child.parent_id\n )\n SELECT 1 FROM ancestors WHERE id=$1::uuid LIMIT 1\n )\n )`,''', ''' `(\n asset.id = $1::uuid\n OR EXISTS (\n WITH RECURSIVE ancestors AS (\n SELECT id,parent_id FROM assets WHERE id=asset.parent_id\n UNION ALL\n SELECT parent.id,parent.parent_id\n FROM assets parent JOIN ancestors child ON parent.id=child.parent_id\n )\n SELECT 1 FROM ancestors WHERE id=$1::uuid LIMIT 1\n )\n )`,''', 1) - text = text.replace('const effectiveParentId = parentId ?? context.areaId;', 'const effectiveParentId = parentId ?? context.scopeAssetId;', 1) - text = text.replace('const parentId = dto.parentId ?? context.areaId;', 'const parentId = dto.parentId ?? context.scopeAssetId;', 1) - text = text.replace(' visit.status,\n visit.operational_area_id AS "areaId",', ' visit.status,\n COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",\n visit.operational_area_id AS "areaId",', 1) - text = text.replace("inspection: { id: context.id, code: context.code, status: context.status },\n area:", "inspection: { id: context.id, code: context.code, status: context.status },\n scopeAssetId: context.scopeAssetId,\n area:", 1) - text = text.replace(' asset.id=$2::uuid\n OR asset.operational_area_id=$2::uuid\n OR EXISTS (', ' asset.id=$2::uuid\n OR EXISTS (', 1) - text = text.replace(' ) AS "insideArea"', ' ) AS "insideScope"', 2) - text = text.replace(' insideArea: boolean;', ' insideScope: boolean;', 2) - text = text.replace('[parentId, context.areaId]', '[parentId, context.scopeAssetId]', 1) - text = text.replace('if (!parent.insideArea)', 'if (!parent.insideScope)', 1) - text = text.replace("message: 'La ubicación padre no pertenece al Área de la inspección'", "message: 'La ubicación padre no pertenece al Yacimiento definido como alcance de la inspección'", 1) - text = text.replace(' asset.operational_area_id=$2::uuid\n OR EXISTS (', ' asset.id=$2::uuid\n OR EXISTS (', 1) - text = text.replace('[assetId, context.areaId]', '[assetId, context.scopeAssetId]', 1) - text = text.replace('if (!asset.insideArea)', 'if (!asset.insideScope)', 1) - text = text.replace("message: 'El registro no pertenece al Área de esta inspección'", "message: 'El registro no pertenece al Yacimiento definido como alcance de esta inspección'", 1) - p.write_text(text) - - # Field Findings: the same server-side scope gate applies before any finding operation. - p = Path('api-v3/src/inspection-visits/field-findings.service.ts') - text = p.read_text() - text = text.replace(' visit.status AS "visitStatus",\n visit.operational_area_id AS "areaId",', ' visit.status AS "visitStatus",\n COALESCE(visit.scope_asset_id, visit.operational_area_id) AS "scopeAssetId",\n visit.operational_area_id AS "areaId",', 1) - before = ''' (\n asset.operational_area_id=visit.operational_area_id\n OR asset.id=visit.operational_area_id\n OR EXISTS (\n WITH RECURSIVE ancestors AS (\n SELECT id,parent_id FROM assets WHERE id=asset.parent_id\n UNION ALL\n SELECT parent.id,parent.parent_id\n FROM assets parent JOIN ancestors child ON parent.id=child.parent_id\n )\n SELECT 1 FROM ancestors WHERE id=visit.operational_area_id LIMIT 1\n )\n ) AS "insideArea",''' - after = ''' (\n asset.id=COALESCE(visit.scope_asset_id, visit.operational_area_id)\n OR EXISTS (\n WITH RECURSIVE ancestors AS (\n SELECT id,parent_id FROM assets WHERE id=asset.parent_id\n UNION ALL\n SELECT parent.id,parent.parent_id\n FROM assets parent JOIN ancestors child ON parent.id=child.parent_id\n )\n SELECT 1 FROM ancestors\n WHERE id=COALESCE(visit.scope_asset_id, visit.operational_area_id)\n LIMIT 1\n )\n ) AS "insideScope",''' - if text.count(before) != 1: - raise SystemExit(f'field findings insideArea block: {text.count(before)}') - text = text.replace(before, after) - text = text.replace(' areaId: string | null;', ' scopeAssetId: string | null;\n areaId: string | null;', 1) - text = text.replace(' insideArea: boolean;', ' insideScope: boolean;', 1) - text = text.replace('if (!row.insideArea)', 'if (!row.insideScope)', 1) - text = text.replace("message: 'El Inventario no pertenece al Área de esta inspección'", "message: 'El Inventario no pertenece al Yacimiento definido como alcance de esta inspección'", 1) - p.write_text(text) - - # New inspection: actionable checklist rows must also select the corresponding inventory. - replace_once( - 'api-v3/src/inspection-visits/inspection-planning-create.service.ts', - ''' stage = 'audit:record';''', - ''' stage = 'checklist:assets';\n await manager.query(`\n INSERT INTO inspection_visit_assets (\n visit_id, asset_id, included, planning_source, added_by\n )\n SELECT DISTINCT $1::uuid, item.asset_id, true, 'AUTOMATIC', $2::uuid\n FROM inspection_visit_checklist_items item\n WHERE item.visit_id=$1::uuid\n AND item.generation_number=1\n AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')\n ON CONFLICT (visit_id,asset_id) DO UPDATE SET\n included=true,\n planning_source=CASE\n WHEN inspection_visit_assets.planning_source='VERIFICATION' THEN 'VERIFICATION'\n ELSE 'AUTOMATIC'\n END,\n exclusion_reason=NULL,\n excluded_by=NULL,\n excluded_at=NULL,\n added_by=EXCLUDED.added_by,\n updated_at=CURRENT_TIMESTAMP\n `, [visit.id, principal.userId]);\n\n stage = 'checklist:asset-events';\n await manager.query(`\n INSERT INTO inspection_visit_asset_events (\n visit_id, asset_id, event_type, reason, actor_user_id, metadata\n )\n SELECT DISTINCT\n $1::uuid, item.asset_id, 'AUTO_INCLUDED', NULL, $2::uuid,\n jsonb_build_object('generation',1,'source','F6.1_CREATE')\n FROM inspection_visit_checklist_items item\n WHERE item.visit_id=$1::uuid\n AND item.generation_number=1\n AND item.item_kind IN ('COMPANY_OVERDUE','VERIFICATION_OVERDUE','UPCOMING_CONTROL')\n AND NOT EXISTS (\n SELECT 1 FROM inspection_visit_asset_events event\n WHERE event.visit_id=$1::uuid\n AND event.asset_id=item.asset_id\n AND event.event_type='AUTO_INCLUDED'\n )\n `, [visit.id, principal.userId]);\n\n stage = 'audit:record';''', - 'create actionable assets', - ) - - # Canonical update/generator: preserve scope, validate it and stop using operator snapshots as membership. - p = Path('api-v3/src/inspection-visits/inspection-visits.service.ts') - text = p.read_text() - text = text.replace("import { nextInspectionVisitCode } from './inspection-visit-code';", "import { nextInspectionVisitCode } from './inspection-visit-code';\nimport { validateInspectionPlanningScope } from './inspection-planning-scope';", 1) - text = text.replace('await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId);', 'await this.validatePlanningContext(manager, operationalAreaId, operatorCompanyId, plannedStartAt);', 1) - before = ''' const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;\n const nextScope = contextTouched\n ? nextAreaId\n : dto.scopeAssetId === undefined\n ? visit.scopeAssetId\n : dto.scopeAssetId;\n await this.validatePlanningContext(manager, nextAreaId, nextCompanyId);\n await this.requireAsset(manager, nextScope);\n if (contextTouched && nextAreaId && nextCompanyId) {\n await this.assertCurrentAssetsMatchContext(manager, id, nextAreaId, nextCompanyId);\n } else if (dto.scopeAssetId !== undefined) {\n await this.assertCurrentAssetsInScope(manager, id, nextScope);\n }''' - after = ''' const contextTouched = dto.operationalAreaId !== undefined || dto.operatorCompanyId !== undefined;\n const nextScope = dto.scopeAssetId === undefined ? visit.scopeAssetId : dto.scopeAssetId;\n await this.validatePlanningContext(manager, nextAreaId, nextCompanyId, nextStart);\n await this.requireAsset(manager, nextScope);\n if (nextAreaId && nextScope) {\n await validateInspectionPlanningScope(manager, nextAreaId, nextScope);\n await this.assertCurrentAssetsInScope(manager, id, nextScope);\n }''' - if text.count(before) != 1: - raise SystemExit(f'update scope block: {text.count(before)}') - text = text.replace(before, after) - before = ''' if (contextTouched) {\n visit.operationalAreaId = nextAreaId;\n visit.operatorCompanyId = nextCompanyId;\n visit.scopeAssetId = nextAreaId;\n } else if (dto.scopeAssetId !== undefined) {\n visit.scopeAssetId = dto.scopeAssetId;\n }''' - after = ''' if (contextTouched) {\n visit.operationalAreaId = nextAreaId;\n visit.operatorCompanyId = nextCompanyId;\n }\n if (dto.scopeAssetId !== undefined) {\n visit.scopeAssetId = dto.scopeAssetId;\n }''' - if text.count(before) != 1: - raise SystemExit(f'update assignment block: {text.count(before)}') - text = text.replace(before, after) - text = text.replace('if (contextTouched || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;', 'if (contextTouched || dto.scopeAssetId !== undefined || dto.plannedStartAt !== undefined) visit.checklistGeneratedAt = null;', 1) - - # Preventive/reinclude actions are constrained by scope, not historical Company snapshot. - old_call = ''' await this.assertAssetsMatchContext(\n manager,\n additions,\n visit.operationalAreaId,\n visit.operatorCompanyId,\n );''' - new_call = ''' await this.assertAssetsInScope(\n manager,\n additions,\n visit.scopeAssetId ?? visit.operationalAreaId,\n );''' - if text.count(old_call) != 1: - raise SystemExit(f'replaceAssets context call: {text.count(old_call)}') - text = text.replace(old_call, new_call) - old_call = ''' await this.assertAssetsMatchContext(\n manager,\n [assetId],\n visit.operationalAreaId,\n visit.operatorCompanyId,\n );''' - new_call = ''' await this.assertAssetsInScope(\n manager,\n [assetId],\n visit.scopeAssetId ?? visit.operationalAreaId,\n );''' - if text.count(old_call) != 1: - raise SystemExit(f'includeAsset context call: {text.count(old_call)}') - text = text.replace(old_call, new_call) - - # Planning context validation can use the visit's planned timestamp. - before = ''' private async validatePlanningContext(\n manager: EntityManager,\n operationalAreaId: string | null,\n operatorCompanyId: string | null,\n ): Promise {''' - after = ''' private async validatePlanningContext(\n manager: EntityManager,\n operationalAreaId: string | null,\n operatorCompanyId: string | null,\n effectiveAt: Date | null = null,\n ): Promise {''' - if text.count(before) != 1: - raise SystemExit(f'validate signature: {text.count(before)}') - text = text.replace(before, after) - text = text.replace(''' AND relation.valid_from <= CURRENT_TIMESTAMP\n AND (relation.valid_until IS NULL OR relation.valid_until > CURRENT_TIMESTAMP)''', ''' AND relation.valid_from <= $3::timestamptz\n AND (relation.valid_until IS NULL OR relation.valid_until > $3::timestamptz)''', 1) - text = text.replace(' `, [operationalAreaId, operatorCompanyId])) as Array<{', ' `, [operationalAreaId, operatorCompanyId, effectiveAt ?? new Date()])) as Array<{', 1) - text = text.replace('await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId);', 'await this.validatePlanningContext(manager, visit.operationalAreaId, visit.operatorCompanyId, visit.plannedStartAt);', 1) - - # Regenerated checklist is Area + Yacimiento scoped and never filters by snapshot Company. - before = ''' WHERE asset.operational_area_id = $3\n AND asset.operator_company_id = $4\n AND finding.status <> 'VOIDED'\n ORDER BY finding.created_at, finding.id\n `, [\n visit.id,\n generation,\n visit.operationalAreaId,\n visit.operatorCompanyId,\n visit.plannedStartAt.toISOString().slice(0, 10),\n ]);''' - after = ''' WHERE asset.operational_area_id = $3\n AND finding.status <> 'VOIDED'\n AND (\n asset.id=$4::uuid\n OR EXISTS (\n WITH RECURSIVE ancestors AS (\n SELECT id,parent_id FROM assets WHERE id=asset.parent_id\n UNION ALL\n SELECT parent.id,parent.parent_id\n FROM assets parent JOIN ancestors child ON parent.id=child.parent_id\n )\n SELECT 1 FROM ancestors WHERE id=$4::uuid LIMIT 1\n )\n )\n ORDER BY finding.created_at, finding.id\n `, [\n visit.id,\n generation,\n visit.operationalAreaId,\n visit.scopeAssetId ?? visit.operationalAreaId,\n visit.plannedStartAt.toISOString().slice(0, 10),\n ]);''' - if text.count(before) != 1: - raise SystemExit(f'generator snapshot filter: {text.count(before)}') - text = text.replace(before, after) - p.write_text(text) - - # Detail page: name Area/Yacimiento separately and explicitly preserve scope on PATCH. - p = Path('web-v2/src/pages/InspectionVisitEditorF4Page.tsx') - text = p.read_text() - text = text.replace(" objective: form.objective.trim() || null,\n operationalAreaId:", " objective: form.objective.trim() || null,\n scopeAssetId: visit?.scopeAsset?.id ?? null,\n operationalAreaId:", 1) - text = text.replace('Área / Yacimiento', 'Área', 1) - anchor = '''