fix(android): scope mobile inspections to yacimiento
DH V2 CI / API · typecheck, tests, build (push) Successful in 5m16s
Android CI / RC / Android · lint, tests, debug APK, release compile (push) Failing after 6m13s
DH V2 CI / WEB · typecheck, build (push) Successful in 4m52s
DH V2 CI / Docker / migrations / production images (push) Skipped
DH V2 CI / Promote verified main to deploy (push) Skipped

This commit is contained in:
2026-09-16 07:33:03 -03:00
parent 29208f4e1f
commit c71afab9af
18 changed files with 287 additions and 70 deletions
+2 -2
View File
@@ -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"
+3 -3
View File
@@ -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.
+2 -2
View File
@@ -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
@@ -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)
}
@@ -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<List<MobilePlanningAsset>>(emptyList()) }
var areas by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var yacimientos by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var operators by remember { mutableStateOf<List<MobilePlanningAsset>>(emptyList()) }
var selectedDepartmentId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedAreaId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedYacimientoId by rememberSaveable { mutableStateOf<String?>(null) }
var selectedCompanyId by rememberSaveable { mutableStateOf<String?>(null) }
var loadingContext by remember { mutableStateOf(false) }
var opening by remember { mutableStateOf(false) }
var localError by remember { mutableStateOf<String?>(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 {
@@ -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()) {
@@ -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),
@@ -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
+2 -2
View File
@@ -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",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dhv2-api",
"version": "0.29.0-13",
"version": "0.29.0-14",
"private": true,
"license": "UNLICENSED",
"scripts": {
@@ -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;
}
@@ -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<void> {
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,
@@ -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,
+2 -2
View File
@@ -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';
+3 -3
View File
@@ -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');
});
+2 -2
View File
@@ -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"/);
});
@@ -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\)/);
});
@@ -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.