From f99452c21c514f259cc386b218c9831ae6908218 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Thu, 10 Sep 2026 15:45:28 -0300 Subject: [PATCH] chore(F6.1): apply reviewed inspection scope fixes --- .github/workflows/f6-1-address-review.yml | 184 ++++++++++++++++++++++ 1 file changed, 184 insertions(+) create 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 new file mode 100644 index 0000000..15b5230 --- /dev/null +++ b/.github/workflows/f6-1-address-review.yml @@ -0,0 +1,184 @@ +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 = '''