diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index ba04f39..305249b 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,5 +1,5 @@ name: Android CI / RC -# F6.13 client-demo barrier: lint + real tests + debug artifact + release compile. +# F6.14 yacimiento-scope barrier: lint + real tests + debug artifact + release compile. on: push: @@ -111,7 +111,7 @@ jobs: cp android-app/app/build/outputs/apk/debug/app-debug.apk "$apk" sha256sum "$apk" > "${apk}.sha256" { - echo "phase=F6.13" + echo "phase=F6.14" echo "version=$version" echo "versionCode=$code" echo "commit=$GITHUB_SHA" diff --git a/android-app/RELEASE.md b/android-app/RELEASE.md index a7efdb3..d50553e 100644 --- a/android-app/RELEASE.md +++ b/android-app/RELEASE.md @@ -1,9 +1,9 @@ -# DH Inspección Android · release de campo 0.19.12 +# DH Inspección Android · release de campo 0.19.13 ## Candidata vigente -- `versionName`: **0.19.12**; `versionCode`: **40**. -- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-13 / WEB 0.23.0-10. +- `versionName`: **0.19.13**; `versionCode`: **41**. +- API: `https://dhv2.korexlabs.com/api/v3/`; compatible con API 0.29.0-14 / WEB 0.23.0-10. - Antes de cerrar el contenido, el inspector escribe una descripción real de lo actuado. - La descripción se sincroniza antes del bloqueo, también cuando se trabajó sin conexión. - Un acta anterior, ya sellada, conserva el texto originalmente registrado. diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index 14762f2..4475c25 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -14,8 +14,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 40 - versionName = "0.19.12" + versionCode = 41 + versionName = "0.19.13" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt index 7b1e56e..022bf7b 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt @@ -25,22 +25,36 @@ data class MobilePlanningAssetResponse( ) data class OpenMobileInspectionRequest( + val departmentId: String, val operationalAreaId: String, + val scopeAssetId: String, val operatorCompanyId: String, ) private interface MobileInspectionOpenApi { - @GET("inspection-visits/mobile/planning-context/areas") - suspend fun areas( + @GET("inspection-visits/mobile/planning-context/departments") + suspend fun departments( @Header("Authorization") authorization: String, ): MobilePlanningAssetResponse - @GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators") - suspend fun operators( + @GET("inspection-visits/mobile/planning-context/departments/{departmentId}/areas") + suspend fun areas( + @Header("Authorization") authorization: String, + @Path("departmentId") departmentId: String, + ): MobilePlanningAssetResponse + + @GET("inspection-visits/mobile/planning-context/areas/{areaId}/yacimientos") + suspend fun yacimientos( @Header("Authorization") authorization: String, @Path("areaId") areaId: String, ): MobilePlanningAssetResponse + @GET("inspection-visits/mobile/planning-context/yacimientos/{yacimientoId}/operators") + suspend fun operators( + @Header("Authorization") authorization: String, + @Path("yacimientoId") yacimientoId: String, + ): MobilePlanningAssetResponse + @POST("inspection-visits/mobile/open") suspend fun open( @Header("Authorization") authorization: String, @@ -61,18 +75,31 @@ class MobileInspectionOpenRepository(context: Context) { .build() .create(MobileInspectionOpenApi::class.java) - suspend fun areas(): MobilePlanningAssetResponse = authorized { session -> - api.areas("Bearer ${session.accessToken}") + suspend fun departments(): MobilePlanningAssetResponse = authorized { session -> + api.departments("Bearer ${session.accessToken}") } - suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session -> - api.operators("Bearer ${session.accessToken}", areaId) + suspend fun areas(departmentId: String): MobilePlanningAssetResponse = authorized { session -> + api.areas("Bearer ${session.accessToken}", departmentId) } - suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session -> + suspend fun yacimientos(areaId: String): MobilePlanningAssetResponse = authorized { session -> + api.yacimientos("Bearer ${session.accessToken}", areaId) + } + + suspend fun operators(yacimientoId: String): MobilePlanningAssetResponse = authorized { session -> + api.operators("Bearer ${session.accessToken}", yacimientoId) + } + + suspend fun open( + departmentId: String, + areaId: String, + yacimientoId: String, + companyId: String, + ): VisitDetail = authorized { session -> api.open( "Bearer ${session.accessToken}", - OpenMobileInspectionRequest(areaId, companyId), + OpenMobileInspectionRequest(departmentId, areaId, yacimientoId, companyId), ) } @@ -89,5 +116,4 @@ class MobileInspectionOpenRepository(context: Context) { private suspend fun refresh(previous: StoredSession): StoredSession = MobileSessionCoordinator.refresh(previous, store::load, store::save, store::clear, api::refresh) - } diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt index 12fa386..e3c9298 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt @@ -47,19 +47,41 @@ fun MobileHomeScreen(model: MainViewModel) { val openRepository = remember(context) { MobileInspectionOpenRepository(context) } var showOpen by rememberSaveable { mutableStateOf(false) } + var departments by remember { mutableStateOf>(emptyList()) } var areas by remember { mutableStateOf>(emptyList()) } + var yacimientos by remember { mutableStateOf>(emptyList()) } var operators by remember { mutableStateOf>(emptyList()) } + var selectedDepartmentId by rememberSaveable { mutableStateOf(null) } var selectedAreaId by rememberSaveable { mutableStateOf(null) } + var selectedYacimientoId by rememberSaveable { mutableStateOf(null) } var selectedCompanyId by rememberSaveable { mutableStateOf(null) } var loadingContext by remember { mutableStateOf(false) } var opening by remember { mutableStateOf(false) } var localError by remember { mutableStateOf(null) } - fun loadAreas() { + fun loadDepartments() { scope.launch { loadingContext = true localError = null - runCatching { openRepository.areas().data } + runCatching { openRepository.departments().data } + .onSuccess { departments = it } + .onFailure { localError = DhRepository.humanError(it) } + loadingContext = false + } + } + + fun chooseDepartment(departmentId: String) { + selectedDepartmentId = departmentId + selectedAreaId = null + selectedYacimientoId = null + selectedCompanyId = null + areas = emptyList() + yacimientos = emptyList() + operators = emptyList() + scope.launch { + loadingContext = true + localError = null + runCatching { openRepository.areas(departmentId).data } .onSuccess { areas = it } .onFailure { localError = DhRepository.humanError(it) } loadingContext = false @@ -68,20 +90,39 @@ fun MobileHomeScreen(model: MainViewModel) { fun chooseArea(areaId: String) { selectedAreaId = areaId + selectedYacimientoId = null + selectedCompanyId = null + yacimientos = emptyList() + operators = emptyList() + scope.launch { + loadingContext = true + localError = null + runCatching { openRepository.yacimientos(areaId).data } + .onSuccess { yacimientos = it } + .onFailure { localError = DhRepository.humanError(it) } + loadingContext = false + } + } + + fun chooseYacimiento(yacimientoId: String) { + selectedYacimientoId = yacimientoId selectedCompanyId = null operators = emptyList() scope.launch { loadingContext = true localError = null - runCatching { openRepository.operators(areaId).data } - .onSuccess { operators = it } + runCatching { openRepository.operators(yacimientoId).data } + .onSuccess { + operators = it + if (it.size == 1) selectedCompanyId = it.first().id + } .onFailure { localError = DhRepository.humanError(it) } loadingContext = false } } LaunchedEffect(showOpen) { - if (showOpen && areas.isEmpty()) loadAreas() + if (showOpen && departments.isEmpty()) loadDepartments() } Column(Modifier.fillMaxSize().padding(top = 28.dp)) { @@ -142,27 +183,57 @@ fun MobileHomeScreen(model: MainViewModel) { ) { Text("Abrir inspección en campo", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text( - "Elegí el Área y la Operadora vigente. La inspección se crea autoasignada a vos y queda iniciada con la fecha y hora del servidor.", + "Definí el contexto completo antes de abrir el Acta: Departamento → Área → Yacimiento → Operadora. La inspección queda autoasignada a vos y se inicia con la hora del servidor.", style = MaterialTheme.typography.bodySmall, ) - Text("1. Área", fontWeight = FontWeight.SemiBold) - if (loadingContext && areas.isEmpty()) CircularProgressIndicator() + Text("1. Departamento", fontWeight = FontWeight.SemiBold) + if (loadingContext && departments.isEmpty()) CircularProgressIndicator() LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { - items(areas, key = { it.id }) { area -> + items(departments, key = { it.id }) { department -> AssistChip( - onClick = { chooseArea(area.id) }, - label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) }, + onClick = { chooseDepartment(department.id) }, + label = { Text(if (department.id == selectedDepartmentId) "✓ ${department.name}" else department.name) }, ) } } + if (selectedDepartmentId != null) { + Text("2. Área", fontWeight = FontWeight.SemiBold) + if (loadingContext && areas.isEmpty()) CircularProgressIndicator() + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(areas, key = { it.id }) { area -> + AssistChip( + onClick = { chooseArea(area.id) }, + label = { Text(if (area.id == selectedAreaId) "✓ ${area.name}" else area.name) }, + ) + } + } + } + if (selectedAreaId != null) { - Text("2. Operadora", fontWeight = FontWeight.SemiBold) + Text("3. Yacimiento", fontWeight = FontWeight.SemiBold) + if (loadingContext && yacimientos.isEmpty()) { + CircularProgressIndicator() + } else if (yacimientos.isEmpty()) { + Text("No hay Yacimientos disponibles para el Área seleccionada.", color = MaterialTheme.colorScheme.error) + } + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(yacimientos, key = { it.id }) { yacimiento -> + AssistChip( + onClick = { chooseYacimiento(yacimiento.id) }, + label = { Text(if (yacimiento.id == selectedYacimientoId) "✓ ${yacimiento.name}" else yacimiento.name) }, + ) + } + } + } + + if (selectedYacimientoId != null) { + Text("4. Operadora", fontWeight = FontWeight.SemiBold) if (loadingContext && operators.isEmpty()) { CircularProgressIndicator() } else if (operators.isEmpty()) { - Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error) + Text("El Yacimiento no tiene una Operadora válida configurada.", color = MaterialTheme.colorScheme.error) } LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { items(operators, key = { it.id }) { company -> @@ -176,12 +247,14 @@ fun MobileHomeScreen(model: MainViewModel) { Button( onClick = { + val departmentId = selectedDepartmentId ?: return@Button val areaId = selectedAreaId ?: return@Button + val yacimientoId = selectedYacimientoId ?: return@Button val companyId = selectedCompanyId ?: return@Button scope.launch { opening = true localError = null - runCatching { openRepository.open(areaId, companyId) } + runCatching { openRepository.open(departmentId, areaId, yacimientoId, companyId) } .onSuccess { opened -> showOpen = false model.openVisit(opened.id) @@ -191,9 +264,9 @@ fun MobileHomeScreen(model: MainViewModel) { } }, modifier = Modifier.fillMaxWidth(), - enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext, + enabled = selectedDepartmentId != null && selectedAreaId != null && selectedYacimientoId != null && selectedCompanyId != null && !opening && !loadingContext, ) { - Text(if (opening) "Abriendo…" else "Abrir inspección ahora") + Text(if (opening) "Abriendo…" else "Abrir inspección y continuar al Acta") } } } @@ -228,6 +301,7 @@ private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) { Text(visit.code, fontWeight = FontWeight.Bold) Text(visitStatusLabelEs(visit.status)) } + Text(visit.scopeAsset?.name ?: "Yacimiento sin definir", fontWeight = FontWeight.SemiBold) Text(visit.operatorCompany?.name ?: "Operadora sin definir") Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall) visit.plannedStartAt?.let { diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt index 9bde94b..95a83b4 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt @@ -261,7 +261,7 @@ fun ModernMobileActsScreen( Column(Modifier.padding(18.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) { Text("Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) Text( - "Elegí una Instalación o Subinstalación existente, o creala en campo si todavía no está registrada.", + "Elegí el Yacimiento, una Instalación o una Subinstalación. El Hallazgo siempre queda dentro del Yacimiento de esta Acta.", color = MaterialTheme.colorScheme.onSurfaceVariant, ) Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt index e1420d6..12d73ec 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt @@ -107,7 +107,9 @@ private data class ModernGeoSnapshot( @Composable fun ModernVisitRoot(model: MainViewModel) { val visit = model.visit ?: return - var screenName by rememberSaveable(visit.id) { mutableStateOf(ModernVisitScreen.OVERVIEW.name) } + var screenName by rememberSaveable(visit.id) { + mutableStateOf(if (visit.status == "IN_PROGRESS") ModernVisitScreen.ACTS.name else ModernVisitScreen.OVERVIEW.name) + } val screen = runCatching { ModernVisitScreen.valueOf(screenName) }.getOrDefault(ModernVisitScreen.OVERVIEW) when (screen) { @@ -938,7 +940,7 @@ private fun ModernInventoryBrowse( OutlinedTextField( value = search, onValueChange = onSearchChange, - label = { Text("Buscar Instalación o Subinstalación") }, + label = { Text("Buscar Yacimiento, Instalación o Subinstalación") }, placeholder = { Text("Nombre, código o dato técnico") }, leadingIcon = { Icon(Icons.Filled.Search, null) }, keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), diff --git a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt index bb8ea09..bfb3c9b 100644 --- a/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt +++ b/android-app/app/src/test/java/com/korexlabs/dhinspeccion/ReleaseMetadataTest.kt @@ -8,8 +8,8 @@ class ReleaseMetadataTest { @Test fun debugBuildKeepsSeparateApplicationIdentity() { assertEquals("com.korexlabs.dhinspeccion.debug", BuildConfig.APPLICATION_ID) - assertEquals(40, BuildConfig.VERSION_CODE) - assertEquals("0.19.12-debug", BuildConfig.VERSION_NAME) + assertEquals(41, BuildConfig.VERSION_CODE) + assertEquals("0.19.13-debug", BuildConfig.VERSION_NAME) } @Test diff --git a/api-v3/package-lock.json b/api-v3/package-lock.json index 0d4c2f6..4bca88a 100644 --- a/api-v3/package-lock.json +++ b/api-v3/package-lock.json @@ -1,12 +1,12 @@ { "name": "dhv2-api", - "version": "0.29.0-13", + "version": "0.29.0-14", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "dhv2-api", - "version": "0.29.0-13", + "version": "0.29.0-14", "license": "UNLICENSED", "dependencies": { "@nestjs/common": "^11.0.0", diff --git a/api-v3/package.json b/api-v3/package.json index 80287b4..6118e33 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.29.0-13", + "version": "0.29.0-14", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/inspection-visits/dto/open-mobile-inspection.dto.ts b/api-v3/src/inspection-visits/dto/open-mobile-inspection.dto.ts index cdb3517..305fb80 100644 --- a/api-v3/src/inspection-visits/dto/open-mobile-inspection.dto.ts +++ b/api-v3/src/inspection-visits/dto/open-mobile-inspection.dto.ts @@ -1,9 +1,15 @@ import { IsUUID } from 'class-validator'; export class OpenMobileInspectionDto { + @IsUUID('4') + departmentId!: string; + @IsUUID('4') operationalAreaId!: string; + @IsUUID('4') + scopeAssetId!: string; + @IsUUID('4') operatorCompanyId!: string; } diff --git a/api-v3/src/inspection-visits/inspection-planning-hierarchy.service.ts b/api-v3/src/inspection-visits/inspection-planning-hierarchy.service.ts index 42e1cd8..73f454e 100644 --- a/api-v3/src/inspection-visits/inspection-planning-hierarchy.service.ts +++ b/api-v3/src/inspection-visits/inspection-planning-hierarchy.service.ts @@ -62,6 +62,67 @@ export class InspectionPlanningHierarchyService { return { data }; } + async operatorsForYacimiento(yacimientoId: string): Promise<{ data: InspectionPlanningHierarchyItem[] }> { + await this.requireType(yacimientoId, 'yacimiento', 'El Yacimiento seleccionado no es válido'); + const data = await this.dataSource.query(` + SELECT company.id, company.code, COALESCE(profile.legal_name,company.name) AS name + FROM assets yacimiento + INNER JOIN assets company ON company.id=yacimiento.operator_company_id + INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id + LEFT JOIN organization_profiles profile ON profile.asset_id=company.id + WHERE yacimiento.id=$1::uuid + AND yacimiento.information_status<>'INACTIVE' + AND company_type.operational_role='COMPANY' + AND company_type.is_active=true + AND company.information_status<>'INACTIVE' + ORDER BY name, company.code + `, [yacimientoId]) as InspectionPlanningHierarchyItem[]; + return { data }; + } + + async validateMobileSelection( + departmentId: string, + areaId: string, + yacimientoId: string, + companyId: string, + ): Promise { + const [row] = await this.dataSource.query(` + SELECT 1 + FROM assets department + INNER JOIN asset_types department_type ON department_type.id=department.asset_type_id + INNER JOIN assets area ON area.parent_id=department.id + INNER JOIN asset_types area_type ON area_type.id=area.asset_type_id + INNER JOIN assets yacimiento ON yacimiento.parent_id=area.id + INNER JOIN asset_types yacimiento_type ON yacimiento_type.id=yacimiento.asset_type_id + INNER JOIN assets company ON company.id=yacimiento.operator_company_id + INNER JOIN asset_types company_type ON company_type.id=company.asset_type_id + WHERE department.id=$1::uuid + AND area.id=$2::uuid + AND yacimiento.id=$3::uuid + AND company.id=$4::uuid + AND lower(department_type.code)='departamento' + AND lower(area_type.code)='area' + AND lower(yacimiento_type.code)='yacimiento' + AND department_type.is_active=true + AND area_type.is_active=true + AND yacimiento_type.is_active=true + AND company_type.operational_role='COMPANY' + AND company_type.is_active=true + AND department.information_status<>'INACTIVE' + AND area.information_status<>'INACTIVE' + AND yacimiento.information_status<>'INACTIVE' + AND company.information_status<>'INACTIVE' + AND yacimiento.concession_type_id IS NOT NULL + LIMIT 1 + `, [departmentId, areaId, yacimientoId, companyId]) as Array<{ '?column?': number }>; + if (!row) { + throw new BadRequestException({ + code: 'INSPECTION_MOBILE_CONTEXT_INVALID', + message: 'La selección debe respetar Departamento → Área → Yacimiento → Operadora', + }); + } + } + async operatorsForArea( areaId: string, at?: string, diff --git a/api-v3/src/inspection-visits/inspection-visits.controller.ts b/api-v3/src/inspection-visits/inspection-visits.controller.ts index 81085ed..d9f68a7 100644 --- a/api-v3/src/inspection-visits/inspection-visits.controller.ts +++ b/api-v3/src/inspection-visits/inspection-visits.controller.ts @@ -87,21 +87,41 @@ export class InspectionVisitsController { return this.planningHierarchy.operatorsForArea(areaId, at); } - @Get('mobile/planning-context/areas') + @Get('mobile/planning-context/departments') @RequirePermissions('inspections.execute') - mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) { + mobilePlanningDepartments(@CurrentAuth() principal: AuthPrincipal) { assertMobileInspector(principal); - return this.visits.listPlanningAreas(); + return this.planningHierarchy.departments(); } - @Get('mobile/planning-context/areas/:areaId/operators') + @Get('mobile/planning-context/departments/:departmentId/areas') @RequirePermissions('inspections.execute') - mobilePlanningOperators( + mobilePlanningAreasForDepartment( + @Param('departmentId', new ParseUUIDPipe({ version: '4' })) departmentId: string, + @CurrentAuth() principal: AuthPrincipal, + ) { + assertMobileInspector(principal); + return this.planningHierarchy.areasForDepartment(departmentId); + } + + @Get('mobile/planning-context/areas/:areaId/yacimientos') + @RequirePermissions('inspections.execute') + mobilePlanningYacimientos( @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string, @CurrentAuth() principal: AuthPrincipal, ) { assertMobileInspector(principal); - return this.visits.listPlanningOperators(areaId); + return this.planningHierarchy.yacimientosForArea(areaId); + } + + @Get('mobile/planning-context/yacimientos/:yacimientoId/operators') + @RequirePermissions('inspections.execute') + mobilePlanningOperatorsForYacimiento( + @Param('yacimientoId', new ParseUUIDPipe({ version: '4' })) yacimientoId: string, + @CurrentAuth() principal: AuthPrincipal, + ) { + assertMobileInspector(principal); + return this.planningHierarchy.operatorsForYacimiento(yacimientoId); } @Post('mobile/open') @@ -112,10 +132,15 @@ export class InspectionVisitsController { @Req() request: RequestWithContext, ) { assertMobileInspector(principal); - // Android conserva por compatibilidad el alcance a nivel Área hasta que su - // flujo también solicite Yacimiento. No se mezcla con la creación WEB F6.1. - const created = await this.visits.create({ + await this.planningHierarchy.validateMobileSelection( + dto.departmentId, + dto.operationalAreaId, + dto.scopeAssetId, + dto.operatorCompanyId, + ); + const created = await this.planningCreate.create({ operationalAreaId: dto.operationalAreaId, + scopeAssetId: dto.scopeAssetId, operatorCompanyId: dto.operatorCompanyId, plannedStartAt: new Date().toISOString(), leadInspectorUserId: principal.userId, diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index f87a9d7..67e08e4 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.29.0-13'; -export const API_PHASE = 'F6.12'; +export const API_VERSION = '0.29.0-14'; +export const API_PHASE = 'F6.14'; diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index 25e8061..ad42ed1 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -4,9 +4,9 @@ import { readFileSync } from 'node:fs'; import { resolve } from 'node:path'; import { API_PHASE, API_VERSION } from '../../src/version'; -test('health metadata reports the current F6.12 release', () => { - assert.equal(API_PHASE, 'F6.12'); +test('health metadata reports the current F6.14 release', () => { + assert.equal(API_PHASE, 'F6.14'); const pkg = JSON.parse(readFileSync(resolve(process.cwd(), 'package.json'), 'utf8')) as { version: string }; assert.equal(API_VERSION, pkg.version); - assert.equal(API_VERSION, '0.29.0-13'); + assert.equal(API_VERSION, '0.29.0-14'); }); diff --git a/api-v3/test/unit/f5-android-test-cut.test.ts b/api-v3/test/unit/f5-android-test-cut.test.ts index 59c3f23..8fbbf92 100644 --- a/api-v3/test/unit/f5-android-test-cut.test.ts +++ b/api-v3/test/unit/f5-android-test-cut.test.ts @@ -10,8 +10,8 @@ function mountedRepoFile(path: string): string { test('F6.3 Android test cut targets production API and has a distinct installable debug version', () => { const gradle = mountedRepoFile('android-app/app/build.gradle.kts'); - assert.match(gradle, /versionCode = 40/); - assert.match(gradle, /versionName = "0\.19\.12"/); + assert.match(gradle, /versionCode = 41/); + assert.match(gradle, /versionName = "0\.19\.13"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); }); diff --git a/api-v3/test/unit/f6-1-findings-mobile-open.test.ts b/api-v3/test/unit/f6-1-findings-mobile-open.test.ts index 998b84d..6eb3bb0 100644 --- a/api-v3/test/unit/f6-1-findings-mobile-open.test.ts +++ b/api-v3/test/unit/f6-1-findings-mobile-open.test.ts @@ -36,33 +36,48 @@ test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-anoth assert.match(androidFinding, /También podés registrar otro Hallazgo sobre el mismo Inventario/); }); -test('F6.1 exposes mobile planning context and opens an inspection self-assigned to the current inspector', () => { +test('F6.14 mobile planning requires Departamento → Área → Yacimiento → Operadora before opening', () => { const controller = source('src/inspection-visits/inspection-visits.controller.ts'); + const hierarchy = source('src/inspection-visits/inspection-planning-hierarchy.service.ts'); const dto = source('src/inspection-visits/dto/open-mobile-inspection.dto.ts'); - assert.match(controller, /@Get\('mobile\/planning-context\/areas'\)/); - assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/operators'\)/); + assert.match(controller, /@Get\('mobile\/planning-context\/departments'\)/); + assert.match(controller, /@Get\('mobile\/planning-context\/departments\/:departmentId\/areas'\)/); + assert.match(controller, /@Get\('mobile\/planning-context\/areas\/:areaId\/yacimientos'\)/); + assert.match(controller, /@Get\('mobile\/planning-context\/yacimientos\/:yacimientoId\/operators'\)/); assert.match(controller, /@Post\('mobile\/open'\)/); - assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/); - assert.match(controller, /assertMobileInspector\(principal\)/); + assert.match(controller, /validateMobileSelection/); + assert.match(controller, /scopeAssetId: dto\.scopeAssetId/); assert.match(controller, /leadInspectorUserId: principal\.userId/); - assert.match(controller, /plannedStartAt: new Date\(\)\.toISOString\(\)/); assert.match(controller, /this\.lifecycle\.plan\(created\.id/); assert.match(controller, /this\.lifecycle\.start\(created\.id/); + assert.match(hierarchy, /Departamento → Área → Yacimiento → Operadora/); + assert.match(dto, /departmentId!: string/); assert.match(dto, /operationalAreaId!: string/); + assert.match(dto, /scopeAssetId!: string/); assert.match(dto, /operatorCompanyId!: string/); }); -test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => { +test('F6.14 Android selects the full territorial chain and enters Actas for an active inspection', () => { const client = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt'); const home = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt'); + const root = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernVisitRoot.kt'); + const acts = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/ModernMobileActsScreen.kt'); const gate = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt'); - assert.match(client, /inspection-visits\/mobile\/planning-context\/areas/); - assert.match(client, /inspection-visits\/mobile\/open/); - assert.match(home, /Abrir inspección/); - assert.match(home, /Abrir inspección ahora/); - assert.match(home, /Operadora vigente/); + assert.match(client, /planning-context\/departments/); + assert.match(client, /departments\/\{departmentId\}\/areas/); + assert.match(client, /areas\/\{areaId\}\/yacimientos/); + assert.match(client, /yacimientos\/\{yacimientoId\}\/operators/); + assert.match(client, /OpenMobileInspectionRequest\(departmentId, areaId, yacimientoId, companyId\)/); + assert.match(home, /1\. Departamento/); + assert.match(home, /2\. Área/); + assert.match(home, /3\. Yacimiento/); + assert.match(home, /4\. Operadora/); + assert.match(home, /Abrir inspección y continuar al Acta/); assert.match(home, /model\.openVisit\(opened\.id\)/); + assert.match(root, /if \(visit\.status == "IN_PROGRESS"\) ModernVisitScreen\.ACTS/); + assert.match(root, /Buscar Yacimiento, Instalación o Subinstalación/); + assert.match(acts, /Elegí el Yacimiento, una Instalación o una Subinstalación/); assert.match(gate, /else -> MobileHomeScreen\(model\)/); }); diff --git a/docs/PHASE_F6_14_MOBILE_YACIMIENTO_SCOPE.md b/docs/PHASE_F6_14_MOBILE_YACIMIENTO_SCOPE.md new file mode 100644 index 0000000..6771882 --- /dev/null +++ b/docs/PHASE_F6_14_MOBILE_YACIMIENTO_SCOPE.md @@ -0,0 +1,8 @@ +# F6.14 · Apertura móvil por Yacimiento + +La APK abre nuevas Inspecciones únicamente después de seleccionar el contexto completo: +Departamento → Área → Yacimiento → Operadora. + +El Yacimiento seleccionado se persiste como `scopeAssetId` de la Inspección. La API valida toda la cadena territorial y que la Operadora corresponda al Yacimiento antes de crear e iniciar la Inspección. + +Una Inspección en curso entra directamente al espacio de Actas. Los Hallazgos pueden registrarse únicamente sobre el Yacimiento de alcance, sus Instalaciones o sus Subinstalaciones. Ningún elemento fuera de esa rama puede incorporarse al trabajo de campo.