fix(F6.1): enforce Yacimiento scope end to end
This commit is contained in:
@@ -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 && <Alert>', '{areaId && plannedStartAt && operators.length === 0 && <Alert>')
|
||||
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<void> {'''
|
||||
after = ''' private async validatePlanningContext(\n manager: EntityManager,\n operationalAreaId: string | null,\n operatorCompanyId: string | null,\n effectiveAt: Date | null = null,\n ): Promise<void> {'''
|
||||
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('<span>Área / Yacimiento</span>', '<span>Área</span>', 1)
|
||||
anchor = ''' <label className="field"><span>Operadora</span>'''
|
||||
insertion = ''' {!isNew && <label className="field"><span>Yacimiento</span><input value={visit?.scopeAsset?.name ?? 'Sin Yacimiento'} readOnly aria-readonly="true" /></label>}\n <label className="field"><span>Operadora</span>'''
|
||||
if text.count(anchor) != 1:
|
||||
raise SystemExit(f'editor operator anchor: {text.count(anchor)}')
|
||||
text = text.replace(anchor, insertion)
|
||||
p.write_text(text)
|
||||
|
||||
# Self-delete after applying the reviewed patch.
|
||||
Path('.github/workflows/f6-1-address-review.yml').unlink()
|
||||
PY
|
||||
|
||||
- name: Sanity checks
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
grep -Fq 'scopeAssetId: context.scopeAssetId' api-v3/src/inspection-visits/field-inventory.service.ts
|
||||
grep -Fq 'AS "insideScope"' api-v3/src/inspection-visits/field-findings.service.ts
|
||||
grep -Fq "planning_source, added_by" api-v3/src/inspection-visits/inspection-planning-create.service.ts
|
||||
grep -Fq 'visit.scopeAssetId ?? visit.operationalAreaId' api-v3/src/inspection-visits/inspection-visits.service.ts
|
||||
grep -Fq 'operators?at=' web-v2/src/pages/InspectionVisitCreateF61Page.tsx
|
||||
grep -Fq 'scopeAssetId: visit?.scopeAsset?.id ?? null' web-v2/src/pages/InspectionVisitEditorF4Page.tsx
|
||||
! grep -Fq '<span>Área / Yacimiento</span>' web-v2/src/pages/InspectionVisitEditorF4Page.tsx
|
||||
|
||||
- name: Commit reviewed fixes
|
||||
run: |
|
||||
set -Eeuo pipefail
|
||||
git config user.name 'github-actions[bot]'
|
||||
git config user.email '41898282+github-actions[bot]@users.noreply.github.com'
|
||||
git add api-v3 web-v2 .github/workflows/f6-1-address-review.yml
|
||||
git diff --cached --check
|
||||
git commit -m 'fix(F6.1): enforce Yacimiento scope end to end'
|
||||
git push origin HEAD:hotfix/f6-1-inspection-planning-hierarchy
|
||||
Reference in New Issue
Block a user