diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index 3f6379b..610cab4 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,5 +1,5 @@ name: Android APK -# F6: genera una APK debug verificable contra la API productiva F6. +# F6.1: genera una APK debug verificable contra la API productiva F6.1. on: push: @@ -11,6 +11,7 @@ on: - 'feature/f2-4*' - 'feature/f3-1*' - 'feature/f3-2*' + - 'feature/f6-1*' paths: - 'android-app/**' - '.github/workflows/android.yml' @@ -62,7 +63,7 @@ jobs: - name: Upload APK uses: actions/upload-artifact@v4 with: - name: DH-Inspeccion-F6-0.14.0-debug + name: DH-Inspeccion-F6.1-0.15.0-debug path: android-app/app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error retention-days: 30 \ No newline at end of file diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index c74bd54..50ca544 100644 --- a/android-app/app/build.gradle.kts +++ b/android-app/app/build.gradle.kts @@ -12,8 +12,8 @@ android { applicationId = "com.korexlabs.dhinspeccion" minSdk = 26 targetSdk = 36 - versionCode = 21 - versionName = "0.14.0" + versionCode = 22 + versionName = "0.15.0" 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 new file mode 100644 index 0000000..c0a5e64 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileInspectionOpen.kt @@ -0,0 +1,103 @@ +package com.korexlabs.dhinspeccion.data + +import android.content.Context +import com.korexlabs.dhinspeccion.BuildConfig +import com.squareup.moshi.Moshi +import com.squareup.moshi.kotlin.reflect.KotlinJsonAdapterFactory +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import okhttp3.OkHttpClient +import retrofit2.HttpException +import retrofit2.Retrofit +import retrofit2.converter.moshi.MoshiConverterFactory +import retrofit2.http.Body +import retrofit2.http.GET +import retrofit2.http.Header +import retrofit2.http.POST +import retrofit2.http.Path + +data class MobilePlanningAsset( + val id: String, + val code: String, + val name: String, +) + +data class MobilePlanningAssetResponse( + val data: List = emptyList(), +) + +data class OpenMobileInspectionRequest( + val operationalAreaId: String, + val operatorCompanyId: String, +) + +private interface MobileInspectionOpenApi { + @GET("inspection-visits/mobile/planning-context/areas") + suspend fun areas( + @Header("Authorization") authorization: String, + ): MobilePlanningAssetResponse + + @GET("inspection-visits/mobile/planning-context/areas/{areaId}/operators") + suspend fun operators( + @Header("Authorization") authorization: String, + @Path("areaId") areaId: String, + ): MobilePlanningAssetResponse + + @POST("inspection-visits/mobile/open") + suspend fun open( + @Header("Authorization") authorization: String, + @Body request: OpenMobileInspectionRequest, + ): VisitDetail + + @POST("auth/mobile/refresh") + suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse +} + +class MobileInspectionOpenRepository(context: Context) { + private val store = SecureSessionStore(context.applicationContext) + private val refreshMutex = Mutex() + private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val api: MobileInspectionOpenApi = Retrofit.Builder() + .baseUrl(BuildConfig.API_BASE_URL) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(MobileInspectionOpenApi::class.java) + + suspend fun areas(): MobilePlanningAssetResponse = authorized { session -> + api.areas("Bearer ${session.accessToken}") + } + + suspend fun operators(areaId: String): MobilePlanningAssetResponse = authorized { session -> + api.operators("Bearer ${session.accessToken}", areaId) + } + + suspend fun open(areaId: String, companyId: String): VisitDetail = authorized { session -> + api.open( + "Bearer ${session.accessToken}", + OpenMobileInspectionRequest(areaId, companyId), + ) + } + + private suspend fun authorized(block: suspend (StoredSession) -> T): T { + var session = store.load() ?: throw IllegalStateException("Sesión no iniciada") + try { + return block(session) + } catch (error: HttpException) { + if (error.code() != 401) throw error + } + session = refresh(session.refreshToken) + return block(session) + } + + private suspend fun refresh(previousRefreshToken: String): StoredSession = refreshMutex.withLock { + val latest = store.load() ?: throw IllegalStateException("Sesión no iniciada") + if (latest.refreshToken != previousRefreshToken) return@withLock latest + try { + store.save(api.refresh(RefreshRequest(previousRefreshToken))) + } catch (error: Throwable) { + store.clear() + throw error + } + } +} diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt index af4acf1..ec13dae 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/LoginGate.kt @@ -92,7 +92,7 @@ fun DhRoot(model: MainViewModel, activity: FragmentActivity) { ) model.fieldFindingOptions != null -> FieldFindingScreen(model) model.visit != null -> F3VisitRoot(model) - else -> DhApp(model) + else -> MobileHomeScreen(model) } } } 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 new file mode 100644 index 0000000..3caec53 --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileHomeScreen.kt @@ -0,0 +1,231 @@ +package com.korexlabs.dhinspeccion.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import com.korexlabs.dhinspeccion.MainViewModel +import com.korexlabs.dhinspeccion.data.DhRepository +import com.korexlabs.dhinspeccion.data.MobileInspectionOpenRepository +import com.korexlabs.dhinspeccion.data.MobilePlanningAsset +import com.korexlabs.dhinspeccion.data.VisitSummary +import kotlinx.coroutines.launch + +@Composable +fun MobileHomeScreen(model: MainViewModel) { + val session = model.session ?: return + val context = LocalContext.current + val scope = rememberCoroutineScope() + val openRepository = remember(context) { MobileInspectionOpenRepository(context) } + + var showOpen by rememberSaveable { mutableStateOf(false) } + var areas by remember { mutableStateOf>(emptyList()) } + var operators by remember { mutableStateOf>(emptyList()) } + var selectedAreaId 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() { + scope.launch { + loadingContext = true + localError = null + runCatching { openRepository.areas().data } + .onSuccess { areas = it } + .onFailure { localError = DhRepository.humanError(it) } + loadingContext = false + } + } + + fun chooseArea(areaId: String) { + selectedAreaId = areaId + selectedCompanyId = null + operators = emptyList() + scope.launch { + loadingContext = true + localError = null + runCatching { openRepository.operators(areaId).data } + .onSuccess { operators = it } + .onFailure { localError = DhRepository.humanError(it) } + loadingContext = false + } + } + + LaunchedEffect(showOpen) { + if (showOpen && areas.isEmpty()) loadAreas() + } + + Column(Modifier.fillMaxSize().padding(top = 28.dp)) { + Row( + Modifier.fillMaxWidth().padding(horizontal = 16.dp, vertical = 10.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + ) { + Column { + Text("Mis inspecciones", style = MaterialTheme.typography.headlineSmall, fontWeight = FontWeight.Bold) + Text(session.displayName, style = MaterialTheme.typography.bodySmall) + } + Row { + OutlinedButton(onClick = { model.loadVisits() }, enabled = !model.busy && !opening) { + Text("Actualizar") + } + Spacer(Modifier.width(8.dp)) + OutlinedButton(onClick = { model.logout() }, enabled = !opening) { Text("Salir") } + } + } + + Column( + Modifier.fillMaxWidth().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + model.error?.let { Text(it, color = MaterialTheme.colorScheme.error) } + model.notice?.let { Text(it, color = MaterialTheme.colorScheme.primary) } + localError?.let { Text(it, color = MaterialTheme.colorScheme.error) } + + Button( + onClick = { + showOpen = !showOpen + localError = null + }, + modifier = Modifier.fillMaxWidth(), + enabled = !opening, + ) { + Text(if (showOpen) "Cancelar nueva inspección" else "Abrir inspección") + } + + if (showOpen) { + Card(Modifier.fillMaxWidth()) { + Column( + Modifier.fillMaxWidth().padding(14.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + 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.", + style = MaterialTheme.typography.bodySmall, + ) + + Text("1. Á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) + if (loadingContext && operators.isEmpty()) { + CircularProgressIndicator() + } else if (operators.isEmpty()) { + Text("No hay una Operadora vigente para el Área seleccionada.", color = MaterialTheme.colorScheme.error) + } + LazyRow(horizontalArrangement = Arrangement.spacedBy(8.dp)) { + items(operators, key = { it.id }) { company -> + AssistChip( + onClick = { selectedCompanyId = company.id }, + label = { Text(if (company.id == selectedCompanyId) "✓ ${company.name}" else company.name) }, + ) + } + } + } + + Button( + onClick = { + val areaId = selectedAreaId ?: return@Button + val companyId = selectedCompanyId ?: return@Button + scope.launch { + opening = true + localError = null + runCatching { openRepository.open(areaId, companyId) } + .onSuccess { opened -> + showOpen = false + model.openVisit(opened.id) + } + .onFailure { localError = DhRepository.humanError(it) } + opening = false + } + }, + modifier = Modifier.fillMaxWidth(), + enabled = selectedAreaId != null && selectedCompanyId != null && !opening && !loadingContext, + ) { + Text(if (opening) "Abriendo…" else "Abrir inspección ahora") + } + } + } + } + } + + if (model.busy && model.visits.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { CircularProgressIndicator() } + } else if (model.visits.isEmpty()) { + Box(Modifier.fillMaxSize(), contentAlignment = Alignment.Center) { + Text("No tenés inspecciones asignadas. Podés abrir una nueva desde esta tablet.") + } + } else { + LazyColumn( + Modifier.fillMaxSize().padding(horizontal = 16.dp, vertical = 10.dp), + verticalArrangement = Arrangement.spacedBy(10.dp), + ) { + items(model.visits, key = { it.id }) { visit -> + MobileVisitCard(visit) { model.openVisit(visit.id) } + } + item { Spacer(Modifier.height(24.dp)) } + } + } + } +} + +@Composable +private fun MobileVisitCard(visit: VisitSummary, onOpen: () -> Unit) { + Card(onClick = onOpen, modifier = Modifier.fillMaxWidth()) { + Column(Modifier.padding(14.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + Text(visit.code, fontWeight = FontWeight.Bold) + Text(visit.status) + } + Text(visit.operatorCompany?.name ?: "Operadora sin definir") + Text(visit.operationalArea?.name ?: "Área sin definir", style = MaterialTheme.typography.bodySmall) + visit.plannedStartAt?.let { + Text("Prevista: ${it.replace('T', ' ').take(16)}", style = MaterialTheme.typography.bodySmall) + } + Text( + "Inventario: ${visit.assetCount} · Equipo inspector: ${visit.memberCount}", + style = MaterialTheme.typography.bodySmall, + ) + } + } +} diff --git a/api-v3/package.json b/api-v3/package.json index 1570c1b..203bae3 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.28.0-1", + "version": "0.29.0-1", "private": true, "license": "UNLICENSED", "scripts": { diff --git a/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts b/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts index d49ba25..058002e 100644 --- a/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts +++ b/api-v3/src/inspection-findings/f3-finding-catalog-resolver.service.ts @@ -1,10 +1,14 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { DataSource } from 'typeorm'; import type { ListFindingCatalogQueryDto } from './dto/list-finding-catalog-query.dto'; +import { FindingCatalogService } from './finding-catalog.service'; @Injectable() export class F3FindingCatalogResolverService { - constructor(private readonly dataSource: DataSource) {} + constructor( + private readonly dataSource: DataSource, + private readonly contextualCatalog: FindingCatalogService, + ) {} async listApplicableForAsset(assetId: string, query: ListFindingCatalogQueryDto) { const [asset] = await this.dataSource.query(` @@ -36,17 +40,28 @@ export class F3FindingCatalogResolverService { enabled: true, code: 'OTHER', label: 'OTROS', - help: 'Usalo cuando el Hallazgo no exista entre los asociados a esta clasificación. Quedará registrado para revisión en oficina.', + help: 'Usalo cuando el Hallazgo no exista entre los asociados a este Inventario. Quedará registrado para revisión en oficina.', }; + if (asset.typeCode.toLowerCase() === 'yacimiento') { + const contextual = await this.contextualCatalog.listApplicableForAsset(assetId, query); + return { + ...contextual, + inventoryFamily: null, + catalogSource: 'ASSET_TYPE' as const, + configurationReason: contextual.configurationReason + ?? 'Hallazgos asociados al tipo Yacimiento. OTROS permanece siempre disponible.', + other, + }; + } + if (!asset.familyId || asset.familyActive !== true) { return { asset: { id: asset.id, code: asset.code, name: asset.name }, inventoryFamily: null, + catalogSource: 'INVENTORY_FAMILY' as const, typeConfigured: false, - configurationReason: asset.typeCode.toLowerCase() === 'yacimiento' - ? 'El Yacimiento es contexto estructural y no posee clasificación técnica. Usá OTROS sólo si necesitás registrar una observación excepcional.' - : 'El elemento todavía no tiene una clasificación técnica activa.', + configurationReason: 'El elemento todavía no tiene una clasificación técnica activa.', categories: [], items: [], other, @@ -103,6 +118,7 @@ export class F3FindingCatalogResolverService { name: asset.familyName, level: asset.familyLevel, }, + catalogSource: 'INVENTORY_FAMILY' as const, typeConfigured: true, configurationReason: `Hallazgos asociados a la clasificación técnica ${asset.familyName ?? asset.familyCode ?? ''}`.trim(), categories, 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 new file mode 100644 index 0000000..cdb3517 --- /dev/null +++ b/api-v3/src/inspection-visits/dto/open-mobile-inspection.dto.ts @@ -0,0 +1,9 @@ +import { IsUUID } from 'class-validator'; + +export class OpenMobileInspectionDto { + @IsUUID('4') + operationalAreaId!: string; + + @IsUUID('4') + operatorCompanyId!: string; +} diff --git a/api-v3/src/inspection-visits/field-findings.service.ts b/api-v3/src/inspection-visits/field-findings.service.ts index a1ef657..1b2ec7e 100644 --- a/api-v3/src/inspection-visits/field-findings.service.ts +++ b/api-v3/src/inspection-visits/field-findings.service.ts @@ -265,13 +265,14 @@ export class FieldFindingsService { message: 'Registro de Inventario no encontrado', }); } - if (!['instalacion', 'subinstalacion'].includes(String(row.assetTypeCode).toLowerCase())) { + const assetTypeCode = String(row.assetTypeCode).toLowerCase(); + if (!['yacimiento', 'instalacion', 'subinstalacion'].includes(assetTypeCode)) { throw new BadRequestException({ - code: 'FIELD_FINDING_TECHNICAL_LEVEL_REQUIRED', - message: 'Los Hallazgos se registran sobre una Instalación o Subinstalación, no sobre Área/Yacimiento', + code: 'FIELD_FINDING_TARGET_LEVEL_INVALID', + message: 'Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación', }); } - if (!row.inventoryFamilyId) { + if (assetTypeCode !== 'yacimiento' && !row.inventoryFamilyId) { throw new ConflictException({ code: 'FIELD_FINDING_FAMILY_REQUIRED', message: 'La Instalación/Subinstalación debe tener una clasificación técnica antes de registrar Hallazgos', diff --git a/api-v3/src/inspection-visits/inspection-visits.controller.ts b/api-v3/src/inspection-visits/inspection-visits.controller.ts index 7be5b20..c93e054 100644 --- a/api-v3/src/inspection-visits/inspection-visits.controller.ts +++ b/api-v3/src/inspection-visits/inspection-visits.controller.ts @@ -13,11 +13,13 @@ import { import { CurrentAuth } from '../auth/decorators/current-auth.decorator'; import { RequirePermissions } from '../authorization/decorators/require-permissions.decorator'; import type { AuthPrincipal, RequestWithContext } from '../common/http/request-context'; +import { assertMobileInspector } from '../inspection-operations/mobile-inspector-policy'; import { CancelInspectionVisitDto } from './dto/cancel-inspection-visit.dto'; import { CloseInspectionVisitDto } from './dto/close-inspection-visit.dto'; import { CreateInspectionVisitDto } from './dto/create-inspection-visit.dto'; import { ExcludeInspectionVisitAssetDto } from './dto/exclude-inspection-visit-asset.dto'; import { ListInspectionVisitsQueryDto } from './dto/list-inspection-visits-query.dto'; +import { OpenMobileInspectionDto } from './dto/open-mobile-inspection.dto'; import { ReplaceInspectionVisitAssetsDto } from './dto/replace-inspection-visit-assets.dto'; import { ReplaceInspectionVisitTeamDto } from './dto/replace-inspection-visit-team.dto'; import { UpdateInspectionVisitDto } from './dto/update-inspection-visit.dto'; @@ -57,6 +59,41 @@ export class InspectionVisitsController { return this.visits.listPlanningOperators(areaId); } + @Get('mobile/planning-context/areas') + @RequirePermissions('inspections.execute') + mobilePlanningAreas(@CurrentAuth() principal: AuthPrincipal) { + assertMobileInspector(principal); + return this.visits.listPlanningAreas(); + } + + @Get('mobile/planning-context/areas/:areaId/operators') + @RequirePermissions('inspections.execute') + mobilePlanningOperators( + @Param('areaId', new ParseUUIDPipe({ version: '4' })) areaId: string, + @CurrentAuth() principal: AuthPrincipal, + ) { + assertMobileInspector(principal); + return this.visits.listPlanningOperators(areaId); + } + + @Post('mobile/open') + @RequirePermissions('inspections.execute') + async openMobile( + @Body() dto: OpenMobileInspectionDto, + @CurrentAuth() principal: AuthPrincipal, + @Req() request: RequestWithContext, + ) { + assertMobileInspector(principal); + const created = await this.visits.create({ + operationalAreaId: dto.operationalAreaId, + operatorCompanyId: dto.operatorCompanyId, + plannedStartAt: new Date().toISOString(), + leadInspectorUserId: principal.userId, + }, principal, request); + await this.lifecycle.plan(created.id, principal, request); + return this.lifecycle.start(created.id, principal, request); + } + @Get(':id') @RequirePermissions('inspections.read') get(@Param('id', new ParseUUIDPipe({ version: '4' })) id: string) { diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 0d0d043..f72dcb1 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.28.0-1'; -export const API_PHASE = 'F6'; \ No newline at end of file +export const API_VERSION = '0.29.0-1'; +export const API_PHASE = 'F6.1'; \ No newline at end of file diff --git a/api-v3/test/unit/f4-health-metadata.test.ts b/api-v3/test/unit/f4-health-metadata.test.ts index 21615db..4584574 100644 --- a/api-v3/test/unit/f4-health-metadata.test.ts +++ b/api-v3/test/unit/f4-health-metadata.test.ts @@ -2,7 +2,7 @@ import assert from 'node:assert/strict'; import test from 'node:test'; import { API_PHASE, API_VERSION } from '../../src/version'; -test('health metadata reports the current F6 release', () => { - assert.equal(API_PHASE, 'F6'); - assert.equal(API_VERSION, '0.28.0-1'); +test('health metadata reports the current F6.1 release', () => { + assert.equal(API_PHASE, 'F6.1'); + assert.equal(API_VERSION, '0.29.0-1'); }); \ No newline at end of file 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 1a89ff6..b6a2d65 100644 --- a/api-v3/test/unit/f5-android-test-cut.test.ts +++ b/api-v3/test/unit/f5-android-test-cut.test.ts @@ -7,16 +7,16 @@ function mountedRepoFile(path: string): string { return readFileSync(resolve(process.cwd(), '..', path), 'utf8'); } -test('F6 Android test cut targets production API and has a distinct installable debug version', () => { +test('F6.1 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 = 21/); - assert.match(gradle, /versionName = "0\.14\.0"/); + assert.match(gradle, /versionCode = 22/); + assert.match(gradle, /versionName = "0\.15\.0"/); assert.match(gradle, /https:\/\/dhv2\.korexlabs\.com\/api\/v3\//); assert.match(gradle, /applicationIdSuffix = "\.debug"/); }); -test('F5/F6 field inventory exposes Other families as reviewable choices to Android', () => { +test('F5/F6.1 field inventory exposes Other families as reviewable choices to Android', () => { const service = readFileSync( resolve(process.cwd(), 'src/inspection-visits/f3-field-inventory-structure.service.ts'), 'utf8', 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 new file mode 100644 index 0000000..0fe7347 --- /dev/null +++ b/api-v3/test/unit/f6-1-findings-mobile-open.test.ts @@ -0,0 +1,67 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import { resolve } from 'node:path'; +import test from 'node:test'; + +function source(path: string) { + return readFileSync(resolve(process.cwd(), path), 'utf8'); +} + +test('F6.1 allows direct findings only on Yacimiento, Instalacion and Subinstalacion', () => { + const findings = source('src/inspection-visits/field-findings.service.ts'); + + assert.match(findings, /\['yacimiento', 'instalacion', 'subinstalacion'\]\.includes\(assetTypeCode\)/); + assert.match(findings, /FIELD_FINDING_TARGET_LEVEL_INVALID/); + assert.match(findings, /Los Hallazgos pueden registrarse sobre Yacimiento, Instalación o Subinstalación/); + assert.match(findings, /assetTypeCode !== 'yacimiento' && !row\.inventoryFamilyId/); + assert.match(findings, /FIELD_FINDING_FAMILY_REQUIRED/); +}); + +test('F6.1 uses contextual type catalog for Yacimiento and keeps OTROS/add-another everywhere', () => { + const findings = source('src/inspection-visits/field-findings.service.ts'); + const resolver = source('src/inspection-findings/f3-finding-catalog-resolver.service.ts'); + const catalog = source('src/inspection-findings/finding-catalog.service.ts'); + const androidFinding = source('../android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/FieldFindingScreen.kt'); + + assert.match(findings, /canAddAnother: true/); + assert.match(resolver, /typeCode\.toLowerCase\(\) === 'yacimiento'/); + assert.match(resolver, /contextualCatalog\.listApplicableForAsset\(assetId, query\)/); + assert.match(resolver, /catalogSource: 'ASSET_TYPE'/); + assert.match(catalog, /finding_catalog_item_asset_types mapping/); + assert.match(catalog, /finding_catalog_asset_overrides override/); + assert.match(resolver, /code: 'OTHER'/); + assert.match(resolver, /label: 'OTROS'/); + assert.match(androidFinding, /OTROS · No está en el catálogo/); + 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', () => { + const controller = source('src/inspection-visits/inspection-visits.controller.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, /@Post\('mobile\/open'\)/); + assert.match(controller, /@RequirePermissions\('inspections\.execute'\)/); + assert.match(controller, /assertMobileInspector\(principal\)/); + 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(dto, /operationalAreaId!: string/); + assert.match(dto, /operatorCompanyId!: string/); +}); + +test('F6.1 Android lets the inspector choose Area and current Operator and open the inspection now', () => { + 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 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(home, /model\.openVisit\(opened\.id\)/); + assert.match(gate, /else -> MobileHomeScreen\(model\)/); +});