From 9479ae6d1a561eb1d4c15dc10fa7f6443abd103e Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:10:49 -0300 Subject: [PATCH 01/22] =?UTF-8?q?F3.2:=20agregar=20selecci=C3=B3n=20expl?= =?UTF-8?q?=C3=ADcita=20de=20Acta=20en=20Hallazgos=20de=20campo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/field-finding-act-query.dto.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts diff --git a/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts b/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts new file mode 100644 index 0000000..5df166f --- /dev/null +++ b/api-v3/src/inspection-visits/dto/field-finding-act-query.dto.ts @@ -0,0 +1,12 @@ +import { IsOptional, IsUUID } from 'class-validator'; + +/** + * Selección explícita del Acta activa desde la APK. + * `actId` queda opcional sólo durante la transición desde Android 0.11.0; + * F3.2 móvil siempre lo informa. + */ +export class FieldFindingActQueryDto { + @IsOptional() + @IsUUID('4') + actId?: string; +} From d3bfdeac6797382c6412b80023be67ba6944d6bd Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:11:05 -0300 Subject: [PATCH 02/22] =?UTF-8?q?F3.2:=20permitir=20Acta=20expl=C3=ADcita?= =?UTF-8?q?=20al=20crear=20Hallazgo=20de=20campo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-v3/src/inspection-visits/dto/create-field-finding.dto.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts b/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts index 9d74192..8adab5f 100644 --- a/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts +++ b/api-v3/src/inspection-visits/dto/create-field-finding.dto.ts @@ -18,8 +18,13 @@ const optionalText = ({ value }: { value: unknown }) => /** * Hallazgo capturado desde la APK sobre un Inventario ya seleccionado. * El assetId se toma de la URL para evitar inconsistencias entre pantalla y payload. + * `actId` queda opcional sólo para compatibilidad con Android 0.11.0; F3.2 siempre lo envía. */ export class CreateFieldFindingDto { + @IsOptional() + @IsUUID('4') + actId?: string; + @IsOptional() @Transform(optionalText) @IsUUID('4') From 45ca6a783e4f9ddc86cbda9e08827a2957283b3b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:11:20 -0300 Subject: [PATCH 03/22] =?UTF-8?q?F3.2:=20recibir=20Acta=20expl=C3=ADcita?= =?UTF-8?q?=20en=20Hallazgos=20de=20campo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-v3/src/inspection-visits/field-findings.controller.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/api-v3/src/inspection-visits/field-findings.controller.ts b/api-v3/src/inspection-visits/field-findings.controller.ts index c4a40c2..d84ac67 100644 --- a/api-v3/src/inspection-visits/field-findings.controller.ts +++ b/api-v3/src/inspection-visits/field-findings.controller.ts @@ -5,12 +5,14 @@ import { Param, ParseUUIDPipe, Post, + Query, Req, } from '@nestjs/common'; 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 { CreateFieldFindingDto } from './dto/create-field-finding.dto'; +import { FieldFindingActQueryDto } from './dto/field-finding-act-query.dto'; import { FieldFindingsService } from './field-findings.service'; @Controller('inspection-visits/:visitId/field-findings') @@ -22,9 +24,10 @@ export class FieldFindingsController { options( @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @Query() query: FieldFindingActQueryDto, @CurrentAuth() principal: AuthPrincipal, ): Promise { - return this.fieldFindings.options(visitId, assetId, principal); + return this.fieldFindings.options(visitId, assetId, query.actId, principal); } @Get(':assetId') @@ -32,9 +35,10 @@ export class FieldFindingsController { list( @Param('visitId', new ParseUUIDPipe({ version: '4' })) visitId: string, @Param('assetId', new ParseUUIDPipe({ version: '4' })) assetId: string, + @Query() query: FieldFindingActQueryDto, @CurrentAuth() principal: AuthPrincipal, ): Promise { - return this.fieldFindings.list(visitId, assetId, principal); + return this.fieldFindings.list(visitId, assetId, query.actId, principal); } @Post(':assetId') From 203443d9c65a6316688f86eb9cd0315c381b62dc Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:12:03 -0300 Subject: [PATCH 04/22] =?UTF-8?q?F3.2:=20resolver=20Hallazgos=20contra=20A?= =?UTF-8?q?cta=20expl=C3=ADcita?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../field-findings.service.ts | 71 +++++++++++++++++-- 1 file changed, 64 insertions(+), 7 deletions(-) diff --git a/api-v3/src/inspection-visits/field-findings.service.ts b/api-v3/src/inspection-visits/field-findings.service.ts index 9aa8218..ad1d6ad 100644 --- a/api-v3/src/inspection-visits/field-findings.service.ts +++ b/api-v3/src/inspection-visits/field-findings.service.ts @@ -36,9 +36,15 @@ export class FieldFindingsService { private readonly findings: InspectionFindingsService, ) {} - async options(visitId: string, assetId: string, principal: AuthPrincipal) { + async options( + visitId: string, + assetId: string, + actId: string | undefined, + principal: AuthPrincipal, + ) { const gate = await this.requireGate(visitId, assetId, principal); - const act = await this.requireDraftAct(visitId); + const act = await this.requireDraftAct(visitId, actId); + const assetIncludedInAct = await this.actContainsAsset(act.id, assetId); const [catalog, findings] = await Promise.all([ this.catalog.listApplicableForAsset(assetId, {}), this.findings.listForAct(act.id), @@ -48,21 +54,30 @@ export class FieldFindingsService { context: gate.context, act, capture: gate.capture, + assetIncludedInAct, catalog, findings: findings.data.filter((finding) => finding.assetId === assetId), - canAddAnother: true, + canAddAnother: assetIncludedInAct, + actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT', }; } - async list(visitId: string, assetId: string, principal: AuthPrincipal) { + async list( + visitId: string, + assetId: string, + actId: string | undefined, + principal: AuthPrincipal, + ) { const gate = await this.requireGate(visitId, assetId, principal); - const act = await this.requireDraftAct(visitId); + const act = await this.requireDraftAct(visitId, actId); const findings = await this.findings.listForAct(act.id); return { context: gate.context, act, capture: gate.capture, + assetIncludedInAct: await this.actContainsAsset(act.id, assetId), data: findings.data.filter((finding) => finding.assetId === assetId), + actSelectionMode: actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT', }; } @@ -74,7 +89,15 @@ export class FieldFindingsService { request: RequestWithContext, ) { const gate = await this.requireGate(visitId, assetId, principal); - const act = await this.requireDraftAct(visitId); + const act = await this.requireDraftAct(visitId, dto.actId); + if (!await this.actContainsAsset(act.id, assetId)) { + throw new ConflictException({ + code: 'FIELD_FINDING_ASSET_NOT_IN_ACT', + message: 'Agregá este Inventario al Acta seleccionada antes de registrar el Hallazgo', + actId: act.id, + assetId, + }); + } const payload: CreateInspectionFindingDto = { assetId, catalogItemId: dto.catalogItemId ?? null, @@ -91,6 +114,7 @@ export class FieldFindingsService { capture: gate.capture, finding, canAddAnother: true, + actSelectionMode: dto.actId ? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT', }; } @@ -268,7 +292,40 @@ export class FieldFindingsService { }; } - private async requireDraftAct(visitId: string): Promise { + private async actContainsAsset(actId: string, assetId: string): Promise { + const [row] = await this.dataSource.query(` + SELECT EXISTS ( + SELECT 1 FROM inspection_act_assets + WHERE act_id=$1::uuid AND asset_id=$2::uuid AND included=true + ) AS included + `, [actId, assetId]) as Array<{ included: boolean }>; + return Boolean(row?.included); + } + + private async requireDraftAct(visitId: string, requestedActId?: string): Promise { + if (requestedActId) { + const [act] = await this.dataSource.query(` + SELECT id,code,status + FROM inspection_acts + WHERE id=$1::uuid AND visit_id=$2::uuid + `, [requestedActId, visitId]) as DraftActRow[]; + if (!act) { + throw new NotFoundException({ + code: 'FIELD_FINDING_ACT_NOT_FOUND', + message: 'El Acta seleccionada no pertenece a esta inspección', + }); + } + if (act.status !== 'DRAFT') { + throw new ConflictException({ + code: 'FIELD_FINDING_ACT_NOT_DRAFT', + message: 'Los Hallazgos nuevos sólo pueden agregarse a un Acta en borrador', + actId: act.id, + actStatus: act.status, + }); + } + return act; + } + const rows = await this.dataSource.query(` SELECT id, code, status FROM inspection_acts From d7184ed537aeecd991b4017ab55d6a2abade5825 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:13:22 -0300 Subject: [PATCH 05/22] =?UTF-8?q?F3.2:=20fijar=20contrato=20multi-Acta=20m?= =?UTF-8?q?=C3=B3vil=20y=20cierre=20secuencial?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../test/unit/f3-2-mobile-multi-acta.test.ts | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 api-v3/test/unit/f3-2-mobile-multi-acta.test.ts diff --git a/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts b/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts new file mode 100644 index 0000000..297b136 --- /dev/null +++ b/api-v3/test/unit/f3-2-mobile-multi-acta.test.ts @@ -0,0 +1,42 @@ +import assert from 'node:assert/strict'; +import { readFileSync } from 'node:fs'; +import test from 'node:test'; + +const read = (path: string) => readFileSync(path, 'utf8'); + +test('F3.2 mantiene múltiples Actas con un solo borrador simultáneo por inspección', () => { + const migration = read('src/database/migrations/1789495200000-phase-f1-1-multi-act-inspections.ts'); + assert.match(migration, /DROP CONSTRAINT IF EXISTS uq_inspection_acts_visit/); + assert.match(migration, /CREATE UNIQUE INDEX uq_inspection_acts_one_draft_per_visit/); + assert.match(migration, /WHERE status = 'DRAFT'/); +}); + +test('F3.2 permite seleccionar el Acta explícitamente al trabajar Hallazgos desde la APK', () => { + const controller = read('src/inspection-visits/field-findings.controller.ts'); + const service = read('src/inspection-visits/field-findings.service.ts'); + const dto = read('src/inspection-visits/dto/create-field-finding.dto.ts'); + assert.match(controller, /FieldFindingActQueryDto/); + assert.match(controller, /query\.actId/); + assert.match(dto, /actId\?: string/); + assert.match(service, /actSelectionMode: actId \? 'EXPLICIT' : 'LEGACY_SINGLE_DRAFT'/); + assert.match(service, /WHERE id=\$1::uuid AND visit_id=\$2::uuid/); + assert.match(service, /FIELD_FINDING_ACT_NOT_DRAFT/); + assert.match(service, /FIELD_FINDING_ASSET_NOT_IN_ACT/); +}); + +test('F3.2 conserva el cierre documental inmutable de cada Acta', () => { + const closing = read('src/inspection-closing/inspection-closing.service.ts'); + assert.match(closing, /INSPECTION_ACT_INSPECTOR_SIGNATURE_REQUIRED/); + assert.match(closing, /INSPECTION_ACT_COMPANY_OUTCOME_REQUIRED/); + assert.match(closing, /finalSha256 = sha256CanonicalJson\(finalSnapshot\)/); + assert.match(closing, /status = 'CLOSED'/); + assert.match(closing, /ensureFrozenReport/); + assert.match(closing, /ensureWordForAct/); +}); + +test('F3.2 cierra la inspección sólo cuando no quedan borradores ni Actas sin firma de inspector', () => { + const visits = read('src/inspection-visits/inspection-visits.service.ts'); + assert.match(visits, /INSPECTION_VISIT_DRAFT_ACTS_PENDING/); + assert.match(visits, /INSPECTION_VISIT_INSPECTOR_SIGNATURE_PENDING/); + assert.match(visits, /actsMayCompleteCompanySignatureLater: true/); +}); From 28f6fb3684ef3d48b88a37df3cf2cb4577381958 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:13:52 -0300 Subject: [PATCH 06/22] =?UTF-8?q?F3.2=20Android:=20enviar=20Acta=20expl?= =?UTF-8?q?=C3=ADcita=20en=20Hallazgos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dhinspeccion/data/FieldFindingsMobile.kt | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt index 8523801..6690364 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/FieldFindingsMobile.kt @@ -22,6 +22,7 @@ import retrofit2.http.Multipart import retrofit2.http.POST import retrofit2.http.Part import retrofit2.http.Path +import retrofit2.http.Query import java.io.File import java.time.Instant @@ -100,12 +101,15 @@ data class FieldFindingEvidenceListResponse( data class FieldFindingOptionsResponse( val act: FieldFindingAct, val capture: CaptureStatus = CaptureStatus(), + val assetIncludedInAct: Boolean = true, val catalog: FieldFindingCatalog, val findings: List = emptyList(), val canAddAnother: Boolean = true, + val actSelectionMode: String = "EXPLICIT", ) data class CreateFieldFindingRequest( + val actId: String, val catalogItemId: String? = null, val customTitle: String? = null, val customLegalBasis: String? = null, @@ -119,6 +123,7 @@ data class FieldFindingCreateResponse( val capture: CaptureStatus = CaptureStatus(), val finding: FieldFindingItem, val canAddAnother: Boolean = true, + val actSelectionMode: String = "EXPLICIT", ) private interface FieldFindingsApi { @@ -127,6 +132,7 @@ private interface FieldFindingsApi { @Header("Authorization") authorization: String, @Path("visitId") visitId: String, @Path("assetId") assetId: String, + @Query("actId") actId: String, ): FieldFindingOptionsResponse @POST("inspection-visits/{visitId}/field-findings/{assetId}") @@ -166,7 +172,7 @@ private interface FieldFindingsApi { /** * Cliente de campo para Hallazgos y sus evidencias append-only. - * Comparte el almacén cifrado de sesión y nunca persiste la contraseña. + * F3.2 exige que la APK identifique explícitamente el Acta activa. */ class FieldFindingsRepository(context: Context) { private val store = SecureSessionStore(context.applicationContext) @@ -179,9 +185,9 @@ class FieldFindingsRepository(context: Context) { .build() .create(FieldFindingsApi::class.java) - suspend fun options(visitId: String, assetId: String): FieldFindingOptionsResponse = + suspend fun options(visitId: String, assetId: String, actId: String): FieldFindingOptionsResponse = authorized { session -> - api.options("Bearer ${session.accessToken}", visitId, assetId) + api.options("Bearer ${session.accessToken}", visitId, assetId, actId) } suspend fun create( From 9f60e671d281b52a78891fa20ecd9990481bf5b8 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:14:43 -0300 Subject: [PATCH 07/22] F3.2 Android: agregar cliente de Actas y cierre --- .../korexlabs/dhinspeccion/data/MobileActs.kt | 447 ++++++++++++++++++ 1 file changed, 447 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt new file mode 100644 index 0000000..22bb6eb --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/data/MobileActs.kt @@ -0,0 +1,447 @@ +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.MediaType.Companion.toMediaType +import okhttp3.MultipartBody +import okhttp3.OkHttpClient +import okhttp3.RequestBody +import okhttp3.RequestBody.Companion.asRequestBody +import okhttp3.RequestBody.Companion.toRequestBody +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.Multipart +import retrofit2.http.PATCH +import retrofit2.http.POST +import retrofit2.http.PUT +import retrofit2.http.Part +import retrofit2.http.Path +import retrofit2.http.Query +import java.io.File +import java.time.Instant + +data class MobileActSummary( + val id: String, + val visitId: String, + val code: String, + val status: String, + val occurredAt: String, + val title: String, + val summary: String, + val observations: String? = null, + val currentVersion: Int = 0, + val closedAt: String? = null, + val closureSha256: String? = null, + val assetCount: Int = 0, + val findingCount: Int = 0, +) + +data class MobileActDetail( + val id: String, + val visitId: String, + val code: String, + val status: String, + val occurredAt: String, + val title: String, + val summary: String, + val observations: String? = null, + val currentVersion: Int = 0, + val closedAt: String? = null, + val closureSha256: String? = null, + val assetCount: Int = 0, + val findingCount: Int = 0, + val assets: List = emptyList(), +) + +data class MobileActListMeta( + val page: Int = 1, + val pageSize: Int = 100, + val total: Int = 0, + val totalPages: Int = 0, +) + +data class MobileActListResponse( + val data: List = emptyList(), + val meta: MobileActListMeta = MobileActListMeta(), +) + +data class CreateMobileActRequest( + val occurredAt: String, + val title: String, + val summary: String, + val observations: String? = null, + val assetIds: List, +) + +data class UpdateMobileActRequest( + val assetIds: List, +) + +data class MobileResponsibleRequest( + val attendanceStatus: String, + val fullName: String? = null, + val documentType: String? = null, + val documentNumber: String? = null, + val position: String? = null, + val email: String? = null, + val phone: String? = null, + val absenceReason: String? = null, +) + +data class MobileResponsible( + val actId: String, + val attendanceStatus: String, + val fullName: String? = null, + val documentType: String? = null, + val documentNumber: String? = null, + val position: String? = null, + val email: String? = null, + val phone: String? = null, + val absenceReason: String? = null, +) + +data class MobileActClosureHeader( + val id: String, + val code: String, + val status: String, + val visitId: String, + val currentVersion: Int = 0, + val closedAt: String? = null, + val closureSha256: String? = null, +) + +data class MobileVisitClosureHeader( + val id: String, + val code: String, + val status: String, + val actualClosedAt: String? = null, +) + +data class MobileSignature( + val id: String, + val signerType: String, + val signerUserId: String? = null, + val signerName: String, + val status: String, + val reason: String? = null, + val companyManifestation: String? = null, + val companyStatement: String? = null, + val signedAt: String? = null, + val imageSha256: String? = null, +) + +data class MobileClosureRecord( + val schemaVersion: String, + val preparedSha256: String, + val preparedAt: String, + val finalSha256: String? = null, + val deviceClosedAt: String? = null, + val serverClosedAt: String? = null, + val uploadMode: String? = null, + val isCurrent: Boolean = true, +) + +data class MobileClosureConsents( + val version: String = "", + val inspector: String = "", + val company: String = "", +) + +data class MobileActClosure( + val act: MobileActClosureHeader, + val visit: MobileVisitClosureHeader, + val responsible: MobileResponsible? = null, + val closure: MobileClosureRecord? = null, + val signatures: List = emptyList(), + val consents: MobileClosureConsents = MobileClosureConsents(), +) + +data class MobileCompanyOutcomeRequest( + val status: String, + val reason: String, +) + +data class MobileCloseActRequest( + val clientClosedAt: String = Instant.now().toString(), + val uploadMode: String = "ONLINE", +) + +data class MobileCloseVisitRequest( + val clientClosedAt: String = Instant.now().toString(), +) + +private interface MobileActsApi { + @GET("inspection-visits/{visitId}/acts") + suspend fun listActs( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Query("pageSize") pageSize: Int = 100, + ): MobileActListResponse + + @GET("inspection-acts/{actId}") + suspend fun act( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + ): MobileActDetail + + @POST("inspection-visits/{visitId}/acts") + suspend fun createAct( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Body request: CreateMobileActRequest, + ): MobileActDetail + + @PATCH("inspection-acts/{actId}") + suspend fun updateAct( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + @Body request: UpdateMobileActRequest, + ): MobileActDetail + + @GET("inspection-acts/{actId}/closure") + suspend fun closure( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + ): MobileActClosure + + @PUT("inspection-acts/{actId}/responsible") + suspend fun responsible( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + @Body request: MobileResponsibleRequest, + ): MobileActClosure + + @POST("inspection-acts/{actId}/ready") + suspend fun ready( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + ): MobileActClosure + + @POST("inspection-acts/{actId}/reopen") + suspend fun reopen( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + ): MobileActClosure + + @Multipart + @POST("inspection-acts/{actId}/signatures/inspector") + suspend fun signInspector( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + @Part file: MultipartBody.Part, + @Part("consentAccepted") consentAccepted: RequestBody, + @Part("clientSignedAt") clientSignedAt: RequestBody, + @Part("latitude") latitude: RequestBody?, + @Part("longitude") longitude: RequestBody?, + @Part("accuracyM") accuracyM: RequestBody?, + @Part("deviceLabel") deviceLabel: RequestBody, + ): MobileActClosure + + @Multipart + @POST("inspection-acts/{actId}/signatures/company") + suspend fun signCompany( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + @Part file: MultipartBody.Part, + @Part("consentAccepted") consentAccepted: RequestBody, + @Part("clientSignedAt") clientSignedAt: RequestBody, + @Part("latitude") latitude: RequestBody?, + @Part("longitude") longitude: RequestBody?, + @Part("accuracyM") accuracyM: RequestBody?, + @Part("deviceLabel") deviceLabel: RequestBody, + @Part("manifestation") manifestation: RequestBody?, + @Part("statement") statement: RequestBody?, + ): MobileActClosure + + @POST("inspection-acts/{actId}/company-outcome") + suspend fun companyOutcome( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + @Body request: MobileCompanyOutcomeRequest, + ): MobileActClosure + + @POST("inspection-acts/{actId}/close") + suspend fun closeAct( + @Header("Authorization") authorization: String, + @Path("actId") actId: String, + @Body request: MobileCloseActRequest, + ): MobileActClosure + + @POST("inspection-visits/{visitId}/close") + suspend fun closeVisit( + @Header("Authorization") authorization: String, + @Path("visitId") visitId: String, + @Body request: MobileCloseVisitRequest, + ): VisitDetail + + @POST("auth/mobile/refresh") + suspend fun refresh(@Body request: RefreshRequest): MobileSessionResponse +} + +class MobileActsRepository(context: Context) { + private val store = SecureSessionStore(context.applicationContext) + private val refreshMutex = Mutex() + private val moshi = Moshi.Builder().addLast(KotlinJsonAdapterFactory()).build() + private val api: MobileActsApi = Retrofit.Builder() + .baseUrl(BuildConfig.API_BASE_URL) + .client(OkHttpClient.Builder().build()) + .addConverterFactory(MoshiConverterFactory.create(moshi)) + .build() + .create(MobileActsApi::class.java) + + suspend fun list(visitId: String): MobileActListResponse = authorized { session -> + api.listActs("Bearer ${session.accessToken}", visitId) + } + + suspend fun get(actId: String): MobileActDetail = authorized { session -> + api.act("Bearer ${session.accessToken}", actId) + } + + suspend fun create(visitId: String, assetId: String, visitCode: String): MobileActDetail = authorized { session -> + api.createAct( + "Bearer ${session.accessToken}", + visitId, + CreateMobileActRequest( + occurredAt = Instant.now().toString(), + title = "Acta de inspección $visitCode", + summary = "Acta de inspección en curso. Los Hallazgos y observaciones se incorporan de forma trazable durante la visita.", + assetIds = listOf(assetId), + ), + ) + } + + suspend fun ensureAsset(actId: String, assetId: String): MobileActDetail { + val detail = get(actId) + if (detail.assets.any { it.id == assetId }) return detail + val ids = (detail.assets.map { it.id } + assetId).distinct() + return authorized { session -> + api.updateAct("Bearer ${session.accessToken}", actId, UpdateMobileActRequest(ids)) + } + } + + suspend fun closure(actId: String): MobileActClosure = authorized { session -> + api.closure("Bearer ${session.accessToken}", actId) + } + + suspend fun setResponsible(actId: String, request: MobileResponsibleRequest): MobileActClosure = authorized { session -> + api.responsible("Bearer ${session.accessToken}", actId, request) + } + + suspend fun prepare(actId: String): MobileActClosure = authorized { session -> + api.ready("Bearer ${session.accessToken}", actId) + } + + suspend fun reopen(actId: String): MobileActClosure = authorized { session -> + api.reopen("Bearer ${session.accessToken}", actId) + } + + suspend fun signInspector( + actId: String, + png: File, + latitude: Double?, + longitude: Double?, + accuracyM: Double?, + ): MobileActClosure = signature(actId, png, latitude, longitude, accuracyM, company = false) + + suspend fun signCompany( + actId: String, + png: File, + latitude: Double?, + longitude: Double?, + accuracyM: Double?, + manifestation: String = "CONFORMITY", + statement: String? = null, + ): MobileActClosure = signature( + actId = actId, + png = png, + latitude = latitude, + longitude = longitude, + accuracyM = accuracyM, + company = true, + manifestation = manifestation, + statement = statement, + ) + + suspend fun companyOutcome(actId: String, status: String, reason: String): MobileActClosure = authorized { session -> + api.companyOutcome( + "Bearer ${session.accessToken}", + actId, + MobileCompanyOutcomeRequest(status, reason.trim()), + ) + } + + suspend fun closeAct(actId: String): MobileActClosure = authorized { session -> + api.closeAct("Bearer ${session.accessToken}", actId, MobileCloseActRequest()) + } + + suspend fun closeVisit(visitId: String): VisitDetail = authorized { session -> + api.closeVisit("Bearer ${session.accessToken}", visitId, MobileCloseVisitRequest()) + } + + private suspend fun signature( + actId: String, + png: File, + latitude: Double?, + longitude: Double?, + accuracyM: Double?, + company: Boolean, + manifestation: String? = null, + statement: String? = null, + ): MobileActClosure = authorized { session -> + val text = "text/plain".toMediaType() + val file = MultipartBody.Part.createFormData( + "file", + png.name, + png.asRequestBody("image/png".toMediaType()), + ) + val consent = "true".toRequestBody(text) + val signedAt = Instant.now().toString().toRequestBody(text) + val device = "DH Android".toRequestBody(text) + val lat = latitude?.toString()?.toRequestBody(text) + val lon = longitude?.toString()?.toRequestBody(text) + val accuracy = accuracyM?.toString()?.toRequestBody(text) + if (company) { + api.signCompany( + "Bearer ${session.accessToken}", actId, file, consent, signedAt, + lat, lon, accuracy, device, + manifestation?.toRequestBody(text), + statement?.trim()?.takeIf { it.isNotBlank() }?.toRequestBody(text), + ) + } else { + api.signInspector( + "Bearer ${session.accessToken}", actId, file, consent, signedAt, + lat, lon, accuracy, device, + ) + } + } + + 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 + } + } +} From d14cf737c0d889b139de5851ee3d9fe388b5e5fc Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:16:09 -0300 Subject: [PATCH 08/22] F3.2 Android: incorporar estado multi-Acta y cierre --- .../korexlabs/dhinspeccion/MainViewModel.kt | 277 +++++++++++++++++- 1 file changed, 267 insertions(+), 10 deletions(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt index daeac63..5124554 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/MainViewModel.kt @@ -16,6 +16,11 @@ import com.korexlabs.dhinspeccion.data.FieldFindingOptionsResponse import com.korexlabs.dhinspeccion.data.FieldFindingsRepository import com.korexlabs.dhinspeccion.data.FieldInventoryItem import com.korexlabs.dhinspeccion.data.FieldType +import com.korexlabs.dhinspeccion.data.MobileActClosure +import com.korexlabs.dhinspeccion.data.MobileActDetail +import com.korexlabs.dhinspeccion.data.MobileActSummary +import com.korexlabs.dhinspeccion.data.MobileActsRepository +import com.korexlabs.dhinspeccion.data.MobileResponsibleRequest import com.korexlabs.dhinspeccion.data.StoredSession import com.korexlabs.dhinspeccion.data.VisitDetail import com.korexlabs.dhinspeccion.data.VisitSummary @@ -26,6 +31,7 @@ import java.time.Instant class MainViewModel(application: Application) : AndroidViewModel(application) { private val repository = DhRepository(application) private val findingsRepository = FieldFindingsRepository(application) + private val actsRepository = MobileActsRepository(application) var session: StoredSession? by mutableStateOf(repository.currentSession()) private set @@ -48,6 +54,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { var selectedFieldAsset: FieldAssetDetail? by mutableStateOf(null) private set + var acts: List by mutableStateOf(emptyList()) + private set + var selectedAct: MobileActDetail? by mutableStateOf(null) + private set + var actClosure: MobileActClosure? by mutableStateOf(null) + private set + var fieldFindingOptions: FieldFindingOptionsResponse? by mutableStateOf(null) private set var lastCreatedFinding: FieldFindingItem? by mutableStateOf(null) @@ -85,6 +98,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { inventory = emptyList() fieldTypes = emptyList() selectedFieldAsset = null + clearActState() clearFindingState() } } @@ -101,6 +115,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fieldTypes = emptyList() selectedFieldAsset = null clearFindingState() + loadActsInternal(id, selectDraft = true) } fun closeVisitView() { @@ -108,6 +123,7 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { inventory = emptyList() fieldTypes = emptyList() selectedFieldAsset = null + clearActState() clearFindingState() loadVisits() } @@ -117,10 +133,51 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { launchBusy { visit = repository.startVisit(id) notice = "Inspección iniciada." + loadActsInternal(id, selectDraft = true) loadVisitsInternal() } } + fun reloadActs() { + val visitId = visit?.id ?: return + launchBusy { loadActsInternal(visitId, selectDraft = selectedAct == null) } + } + + fun selectAct(actId: String) { + launchBusy { + selectedAct = actsRepository.get(actId) + actClosure = actsRepository.closure(actId) + clearFindingState() + } + } + + fun createActForSelectedInventory() { + val currentVisit = visit ?: return + val asset = selectedFieldAsset?.asset + if (currentVisit.status != "IN_PROGRESS") { + error = "La inspección debe estar en curso para crear un Acta." + return + } + if (asset == null) { + error = "Seleccioná primero una Instalación o Subinstalación para iniciar el Acta." + return + } + if (acts.any { it.status == "DRAFT" }) { + error = "Ya existe un Acta en borrador. Cerrala o cancelala antes de crear la siguiente." + return + } + launchBusy { + val created = actsRepository.create(currentVisit.id, asset.id, currentVisit.code) + selectedAct = created + actClosure = actsRepository.closure(created.id) + loadActsInternal(currentVisit.id, selectDraft = false) + notice = "${created.code} creada. Los Hallazgos nuevos quedarán vinculados explícitamente a esta Acta." + if (selectedFieldAsset?.capture?.readyForFinding == true) { + loadFindingOptionsInternal(currentVisit.id, asset.id, created.id) + } + } + } + fun searchInventory(search: String, parentId: String? = null) { val id = visit?.id ?: return launchBusy { @@ -141,8 +198,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { selectedFieldAsset = repository.selectFieldAsset(visitId, item.id) notice = "Inventario agregado a la inspección." inventory = repository.fieldInventory(visitId, null, null).data - if (selectedFieldAsset?.capture?.readyForFinding == true) { - loadFindingOptionsInternal(visitId, item.id) + val draft = selectedDraftAct() + if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { + selectedAct = actsRepository.ensureAsset(draft.id, item.id) + loadActsInternal(visitId, selectDraft = false) + loadFindingOptionsInternal(visitId, item.id, draft.id) + } else if (selectedFieldAsset?.capture?.readyForFinding == true) { + notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos." } } } @@ -207,8 +269,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { selectedFieldAsset = repository.selectFieldAsset(visitId, result.canonical.id) notice = "Fusión registrada. Se conserva ${result.canonical.code} y la historia de ${result.source.code} permanece trazable." inventory = repository.fieldInventory(visitId, null, null).data - if (selectedFieldAsset?.capture?.readyForFinding == true) { - loadFindingOptionsInternal(visitId, result.canonical.id) + val draft = selectedDraftAct() + if (selectedFieldAsset?.capture?.readyForFinding == true && draft != null) { + selectedAct = actsRepository.ensureAsset(draft.id, result.canonical.id) + loadActsInternal(visitId, selectDraft = false) + loadFindingOptionsInternal(visitId, result.canonical.id, draft.id) } } } @@ -237,8 +302,13 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { "Fotografía registrada." } inventory = repository.fieldInventory(visitId, null, null).data - if (response.capture.readyForFinding) { - loadFindingOptionsInternal(visitId, asset.id) + val draft = selectedDraftAct() + if (response.capture.readyForFinding && draft != null) { + selectedAct = actsRepository.ensureAsset(draft.id, asset.id) + loadActsInternal(visitId, selectDraft = false) + loadFindingOptionsInternal(visitId, asset.id, draft.id) + } else if (response.capture.readyForFinding) { + notice = "Inventario listo. Creá o seleccioná un Acta antes de registrar Hallazgos." } } } @@ -246,7 +316,16 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { fun openFindingForSelected() { val visitId = visit?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return - launchBusy { loadFindingOptionsInternal(visitId, assetId) } + val draft = selectedDraftAct() + if (draft == null) { + error = "Creá o seleccioná el Acta en borrador antes de registrar Hallazgos." + return + } + launchBusy { + selectedAct = actsRepository.ensureAsset(draft.id, assetId) + loadActsInternal(visitId, selectDraft = false) + loadFindingOptionsInternal(visitId, assetId, draft.id) + } } fun createFieldFinding( @@ -259,6 +338,11 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) { val visitId = visit?.id ?: return val assetId = selectedFieldAsset?.asset?.id ?: return + val actId = selectedDraftAct()?.id + if (actId == null) { + error = "No hay un Acta en borrador seleccionada." + return + } if (description.isBlank()) { error = "Describí el Hallazgo antes de guardarlo." return @@ -272,10 +356,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { return } launchBusy { + selectedAct = actsRepository.ensureAsset(actId, assetId) val response = findingsRepository.create( visitId, assetId, CreateFieldFindingRequest( + actId = actId, catalogItemId = catalogItemId, customTitle = customTitle?.trim()?.takeIf { it.isNotBlank() }, customLegalBasis = customLegalBasis?.trim()?.takeIf { it.isNotBlank() }, @@ -285,8 +371,9 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ), ) lastCreatedFinding = response.finding - notice = "Hallazgo ${response.finding.code} registrado. Podés agregar evidencia fotográfica." - loadFindingOptionsInternal(visitId, assetId, keepLastCreated = true) + notice = "Hallazgo ${response.finding.code} registrado en ${response.act.code}. Podés agregar evidencia fotográfica." + loadFindingOptionsInternal(visitId, assetId, actId, keepLastCreated = true) + loadActsInternal(visitId, selectDraft = false) } } @@ -318,6 +405,139 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { launchBusy { loadEvidenceInternal(findingId) } } + fun setCompanyResponsiblePresent( + fullName: String, + documentType: String, + documentNumber: String, + position: String, + email: String?, + phone: String?, + ) { + val actId = selectedAct?.id ?: return + if (fullName.isBlank() || documentNumber.isBlank() || position.isBlank()) { + error = "Completá nombre, documento y cargo del responsable de la empresa." + return + } + launchBusy { + actClosure = actsRepository.setResponsible( + actId, + MobileResponsibleRequest( + attendanceStatus = "PRESENT", + fullName = fullName.trim(), + documentType = documentType, + documentNumber = documentNumber.trim(), + position = position.trim(), + email = email?.trim()?.takeIf { it.isNotBlank() }, + phone = phone?.trim()?.takeIf { it.isNotBlank() }, + ), + ) + notice = "Responsable de empresa registrado para el Acta." + } + } + + fun setCompanyResponsibleAbsent(reason: String) { + val actId = selectedAct?.id ?: return + if (reason.trim().length < 10) { + error = "Indicá un motivo de ausencia de al menos 10 caracteres." + return + } + launchBusy { + actClosure = actsRepository.setResponsible( + actId, + MobileResponsibleRequest( + attendanceStatus = "ABSENT", + absenceReason = reason.trim(), + ), + ) + notice = "Ausencia del responsable registrada." + } + } + + fun prepareSelectedAct() { + val actId = selectedAct?.id ?: return + launchBusy { + actClosure = actsRepository.prepare(actId) + refreshSelectedActInternal(actId) + notice = "Acta preparada. Su contenido quedó congelado para las firmas." + } + } + + fun reopenSelectedAct() { + val actId = selectedAct?.id ?: return + launchBusy { + actClosure = actsRepository.reopen(actId) + refreshSelectedActInternal(actId) + notice = "Acta reabierta. Podés corregirla antes de volver a preparar." + } + } + + fun signSelectedActAsInspector( + png: File, + latitude: Double?, + longitude: Double?, + accuracyM: Double?, + ) { + val actId = selectedAct?.id ?: return + launchBusy { + actClosure = actsRepository.signInspector(actId, png, latitude, longitude, accuracyM) + notice = "Firma del inspector incorporada al Acta." + } + } + + fun signSelectedActAsCompany( + png: File, + latitude: Double?, + longitude: Double?, + accuracyM: Double?, + manifestation: String, + statement: String?, + ) { + val actId = selectedAct?.id ?: return + launchBusy { + actClosure = actsRepository.signCompany( + actId, png, latitude, longitude, accuracyM, manifestation, statement, + ) + notice = if (manifestation == "DISSENT") { + "Firma de empresa registrada con disidencia." + } else { + "Firma de empresa registrada." + } + } + } + + fun recordCompanyOutcome(status: String, reason: String) { + val actId = selectedAct?.id ?: return + if (reason.trim().length < 10) { + error = "Indicá un motivo de al menos 10 caracteres." + return + } + launchBusy { + actClosure = actsRepository.companyOutcome(actId, status, reason) + notice = if (status == "ABSENT") "Ausencia de empresa asentada." else "Negativa a firmar asentada." + } + } + + fun closeSelectedAct() { + val currentVisit = visit ?: return + val actId = selectedAct?.id ?: return + launchBusy { + actClosure = actsRepository.closeAct(actId) + refreshSelectedActInternal(actId) + loadActsInternal(currentVisit.id, selectDraft = false) + clearFindingState() + notice = "${selectedAct?.code ?: "Acta"} cerrada e inmutable. Podés crear otra Acta o finalizar la inspección." + } + } + + fun closeInspection() { + val visitId = visit?.id ?: return + launchBusy { + visit = actsRepository.closeVisit(visitId) + loadVisitsInternal() + notice = "Inspección cerrada. Las Actas y documentos quedan disponibles para oficina." + } + } + fun clearFindingFlow() { fieldFindingOptions = null lastCreatedFinding = null @@ -330,12 +550,43 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { clearFindingState() } + private fun selectedDraftAct(): MobileActDetail? = + selectedAct?.takeIf { it.status == "DRAFT" } + ?: acts.firstOrNull { it.status == "DRAFT" }?.let { summary -> + selectedAct?.takeIf { it.id == summary.id && it.status == "DRAFT" } + } + + private suspend fun loadActsInternal(visitId: String, selectDraft: Boolean) { + acts = actsRepository.list(visitId).data + val currentId = selectedAct?.id + val current = currentId?.let { id -> acts.firstOrNull { it.id == id } } + val target = when { + current != null -> current.id + selectDraft -> acts.firstOrNull { it.status == "DRAFT" }?.id + else -> null + } + if (target != null) { + selectedAct = actsRepository.get(target) + actClosure = actsRepository.closure(target) + } else if (current == null) { + selectedAct = null + actClosure = null + } + } + + private suspend fun refreshSelectedActInternal(actId: String) { + selectedAct = actsRepository.get(actId) + actClosure = actsRepository.closure(actId) + visit?.id?.let { loadActsInternal(it, selectDraft = false) } + } + private suspend fun loadFindingOptionsInternal( visitId: String, assetId: String, + actId: String, keepLastCreated: Boolean = false, ) { - val options = findingsRepository.options(visitId, assetId) + val options = findingsRepository.options(visitId, assetId, actId) fieldFindingOptions = options if (!keepLastCreated) lastCreatedFinding = null val loaded = linkedMapOf>() @@ -351,6 +602,12 @@ class MainViewModel(application: Application) : AndroidViewModel(application) { ) } + private fun clearActState() { + acts = emptyList() + selectedAct = null + actClosure = null + } + private fun clearFindingState() { fieldFindingOptions = null lastCreatedFinding = null From c9f14344621506a2fb7e2a0519730ad49ef60ae4 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:18:28 -0300 Subject: [PATCH 09/22] F3.2 Android: agregar pad manuscrito PNG para firmas --- .../korexlabs/dhinspeccion/ui/SignaturePad.kt | 138 ++++++++++++++++++ 1 file changed, 138 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt new file mode 100644 index 0000000..ad1150c --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt @@ -0,0 +1,138 @@ +package com.korexlabs.dhinspeccion.ui + +import android.graphics.Bitmap +import android.graphics.Canvas as AndroidCanvas +import android.graphics.Color as AndroidColor +import android.graphics.Paint +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateListOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.StrokeCap +import androidx.compose.ui.input.pointer.consume +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.IntSize +import androidx.compose.ui.unit.dp +import java.io.File +import java.io.FileOutputStream + +@Composable +fun SignaturePad( + label: String, + enabled: Boolean = true, + onCaptured: (File) -> Unit, +) { + val context = LocalContext.current + val strokes = remember { mutableStateListOf>() } + var currentStroke by remember { mutableStateOf>(emptyList()) } + var canvasSize by remember { mutableStateOf(IntSize.Zero) } + + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text(label, fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold) + Text( + "Firmá dentro del recuadro. La imagen se guarda como PNG y se incorpora al hash del Acta.", + style = MaterialTheme.typography.bodySmall, + ) + Canvas( + modifier = Modifier + .fillMaxWidth() + .height(190.dp) + .background(Color.White) + .onSizeChanged { canvasSize = it } + .pointerInput(enabled) { + if (!enabled) return@pointerInput + detectDragGestures( + onDragStart = { position -> currentStroke = listOf(position) }, + onDrag = { change, _ -> + change.consume() + currentStroke = currentStroke + change.position + }, + onDragEnd = { + if (currentStroke.size > 1) strokes.add(currentStroke) + currentStroke = emptyList() + }, + onDragCancel = { currentStroke = emptyList() }, + ) + }, + ) { + val all = strokes + listOf(currentStroke) + all.forEach { stroke -> + stroke.zipWithNext().forEach { (start, end) -> + drawLine( + color = Color.Black, + start = start, + end = end, + strokeWidth = 4.dp.toPx(), + cap = StrokeCap.Round, + ) + } + } + } + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + OutlinedButton( + onClick = { strokes.clear(); currentStroke = emptyList() }, + enabled = enabled && (strokes.isNotEmpty() || currentStroke.isNotEmpty()), + modifier = Modifier.weight(1f), + ) { Text("Limpiar") } + Button( + onClick = { + val width = canvasSize.width.coerceAtLeast(1) + val height = canvasSize.height.coerceAtLeast(1) + val targetWidth = 1000 + val targetHeight = 400 + val scaleX = targetWidth.toFloat() / width.toFloat() + val scaleY = targetHeight.toFloat() / height.toFloat() + val bitmap = Bitmap.createBitmap(targetWidth, targetHeight, Bitmap.Config.ARGB_8888) + val native = AndroidCanvas(bitmap) + native.drawColor(AndroidColor.WHITE) + val paint = Paint(Paint.ANTI_ALIAS_FLAG).apply { + color = AndroidColor.BLACK + strokeWidth = 7f + strokeCap = Paint.Cap.ROUND + strokeJoin = Paint.Join.ROUND + style = Paint.Style.STROKE + } + strokes.forEach { stroke -> + stroke.zipWithNext().forEach { (start, end) -> + native.drawLine( + start.x * scaleX, + start.y * scaleY, + end.x * scaleX, + end.y * scaleY, + paint, + ) + } + } + val file = File.createTempFile("DH_FIRMA_", ".png", context.cacheDir) + FileOutputStream(file).use { stream -> + check(bitmap.compress(Bitmap.CompressFormat.PNG, 100, stream)) + } + bitmap.recycle() + onCaptured(file) + }, + enabled = enabled && strokes.isNotEmpty(), + modifier = Modifier.weight(1f), + ) { Text("Usar firma") } + } + } +} From 97a774c2649a7cbf49fa87570dac90431086b5b9 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:20:08 -0300 Subject: [PATCH 10/22] =?UTF-8?q?F3.2=20Android:=20implementar=20gesti?= =?UTF-8?q?=C3=B3n=20y=20cierre=20secuencial=20de=20Actas?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dhinspeccion/ui/MobileActsScreen.kt | 423 ++++++++++++++++++ 1 file changed, 423 insertions(+) create mode 100644 android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt new file mode 100644 index 0000000..783478b --- /dev/null +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -0,0 +1,423 @@ +package com.korexlabs.dhinspeccion.ui + +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.AssistChip +import androidx.compose.material3.Button +import androidx.compose.material3.Card +import androidx.compose.material3.CardDefaults +import androidx.compose.material3.HorizontalDivider +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.OutlinedTextField +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.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +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 androidx.core.content.ContextCompat +import com.google.android.gms.location.LocationServices +import com.google.android.gms.location.Priority +import com.google.android.gms.tasks.CancellationTokenSource +import com.korexlabs.dhinspeccion.MainViewModel +import kotlinx.coroutines.launch +import kotlinx.coroutines.suspendCancellableCoroutine +import java.io.File +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException + +private data class ActSignatureGeo( + val latitude: Double, + val longitude: Double, + val accuracyM: Double?, +) + +@Composable +fun MobileActsScreen( + model: MainViewModel, + onBack: () -> Unit, + onGoInventory: () -> Unit, +) { + val visit = model.visit ?: return + val selected = model.selectedAct + val closure = model.actClosure + val context = LocalContext.current + val scope = rememberCoroutineScope() + + var attendance by rememberSaveable(selected?.id) { + mutableStateOf(closure?.responsible?.attendanceStatus ?: "PRESENT") + } + var fullName by rememberSaveable(selected?.id) { + mutableStateOf(closure?.responsible?.fullName.orEmpty()) + } + var documentType by rememberSaveable(selected?.id) { + mutableStateOf(closure?.responsible?.documentType ?: "DNI") + } + var documentNumber by rememberSaveable(selected?.id) { + mutableStateOf(closure?.responsible?.documentNumber.orEmpty()) + } + var position by rememberSaveable(selected?.id) { + mutableStateOf(closure?.responsible?.position.orEmpty()) + } + var email by rememberSaveable(selected?.id) { + mutableStateOf(closure?.responsible?.email.orEmpty()) + } + var phone by rememberSaveable(selected?.id) { + mutableStateOf(closure?.responsible?.phone.orEmpty()) + } + var absenceReason by rememberSaveable(selected?.id) { + mutableStateOf(closure?.responsible?.absenceReason.orEmpty()) + } + var refusalReason by rememberSaveable(selected?.id) { mutableStateOf("") } + var manifestation by rememberSaveable(selected?.id) { mutableStateOf("CONFORMITY") } + var dissentStatement by rememberSaveable(selected?.id) { mutableStateOf("") } + + LaunchedEffect(visit.id) { model.reloadActs() } + + fun signWithGeo(file: File, company: Boolean) { + scope.launch { + val geo = runCatching { currentActSignatureGeo(context) }.getOrNull() + if (company) { + model.signSelectedActAsCompany( + png = file, + latitude = geo?.latitude, + longitude = geo?.longitude, + accuracyM = geo?.accuracyM, + manifestation = manifestation, + statement = dissentStatement.takeIf { manifestation == "DISSENT" }, + ) + } else { + model.signSelectedActAsInspector( + png = file, + latitude = geo?.latitude, + longitude = geo?.longitude, + accuracyM = geo?.accuracyM, + ) + } + } + } + + Column( + Modifier + .fillMaxSize() + .verticalScroll(rememberScrollState()) + .padding(top = 28.dp, start = 16.dp, end = 16.dp, bottom = 36.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) { + OutlinedButton(onClick = onBack, enabled = !model.busy) { Text("Volver") } + Text("Actas", style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold) + } + Text("${visit.code} · ${visit.operatorCompany?.name.orEmpty()}") + F32ActMessage(model) + + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Actas de esta inspección", fontWeight = FontWeight.Bold) + if (model.acts.isEmpty()) { + Text("Todavía no hay Actas. La primera se inicia sobre una Instalación/Subinstalación seleccionada.") + } + model.acts.forEach { act -> + val active = selected?.id == act.id + OutlinedButton( + onClick = { model.selectAct(act.id) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text( + (if (active) "✓ " else "") + + "${act.code} · ${actStatusLabel(act.status)} · ${act.findingCount} Hallazgo${if (act.findingCount == 1) "" else "s"}", + ) + } + } + } + } + + val hasOpenAct = model.acts.any { it.status == "DRAFT" || it.status == "READY" } + if (visit.status == "IN_PROGRESS" && !hasOpenAct) { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { + Text("Nueva Acta", fontWeight = FontWeight.Bold) + val selectedInventory = model.selectedFieldAsset?.asset + if (selectedInventory == null) { + Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") + Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { + Text("Ir a Inventario y elegir") + } + } else { + Text("Inventario inicial: ${selectedInventory.name} · ${selectedInventory.code}") + Button( + onClick = { model.createActForSelectedInventory() }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Crear nueva Acta") } + } + } + } + } + + if (selected != null) { + HorizontalDivider() + Card(Modifier.fillMaxWidth()) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text(selected.code, style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text(actStatusLabel(selected.status)) + Text("${selected.findingCount} Hallazgo${if (selected.findingCount == 1) "" else "s"} · ${selected.assetCount} elemento${if (selected.assetCount == 1) "" else "s"} de Inventario") + Text(selected.summary, style = MaterialTheme.typography.bodySmall) + selected.closureSha256?.let { Text("Hash final: $it", style = MaterialTheme.typography.bodySmall) } + } + } + + when (selected.status) { + "DRAFT" -> { + Text("1. Responsable de la empresa", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = { attendance = "PRESENT" }, label = { Text(if (attendance == "PRESENT") "✓ Presente" else "Presente") }) + AssistChip(onClick = { attendance = "ABSENT" }, label = { Text(if (attendance == "ABSENT") "✓ Ausente" else "Ausente") }) + } + if (attendance == "PRESENT") { + OutlinedTextField(fullName, { fullName = it }, label = { Text("Nombre y apellido *") }, modifier = Modifier.fillMaxWidth()) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(6.dp)) { + listOf("DNI", "CUIL", "PASSPORT", "OTHER").forEach { kind -> + AssistChip(onClick = { documentType = kind }, label = { Text(if (documentType == kind) "✓ $kind" else kind) }) + } + } + OutlinedTextField(documentNumber, { documentNumber = it }, label = { Text("Documento *") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(position, { position = it }, label = { Text("Cargo *") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(email, { email = it }, label = { Text("Email") }, modifier = Modifier.fillMaxWidth()) + OutlinedTextField(phone, { phone = it }, label = { Text("Teléfono") }, modifier = Modifier.fillMaxWidth()) + Button( + onClick = { + model.setCompanyResponsiblePresent(fullName, documentType, documentNumber, position, email, phone) + }, + enabled = !model.busy && fullName.isNotBlank() && documentNumber.isNotBlank() && position.isNotBlank(), + modifier = Modifier.fillMaxWidth(), + ) { Text("Guardar responsable") } + } else { + OutlinedTextField( + absenceReason, + { absenceReason = it }, + label = { Text("Motivo de ausencia *") }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + Button( + onClick = { model.setCompanyResponsibleAbsent(absenceReason) }, + enabled = !model.busy && absenceReason.trim().length >= 10, + modifier = Modifier.fillMaxWidth(), + ) { Text("Guardar ausencia") } + } + + HorizontalDivider() + Text("2. Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita.") + Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") } + + HorizontalDivider() + Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Al preparar, el contenido se congela y se calcula su hash. Después sólo se admiten firmas y resultado de empresa.") + Button( + onClick = { model.prepareSelectedAct() }, + enabled = !model.busy && closure?.responsible != null && selected.findingCount > 0, + modifier = Modifier.fillMaxWidth(), + ) { Text("Preparar Acta para firmas") } + } + + "READY" -> { + val signatures = closure?.signatures.orEmpty() + val inspectorSigned = signatures.any { it.signerType == "INSPECTOR" && it.status == "SIGNED" } + val companyOutcome = signatures.firstOrNull { it.signerType == "COMPANY_RESPONSIBLE" } + + Text("Acta preparada", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + closure?.closure?.preparedSha256?.let { Text("Hash preparado: $it", style = MaterialTheme.typography.bodySmall) } + if (signatures.isEmpty()) { + OutlinedButton(onClick = { model.reopenSelectedAct() }, enabled = !model.busy, modifier = Modifier.fillMaxWidth()) { + Text("Volver a borrador") + } + } + + HorizontalDivider() + Text("Firma del inspector", fontWeight = FontWeight.Bold) + if (inspectorSigned) { + Text("✓ Firma del inspector registrada", color = MaterialTheme.colorScheme.primary) + } else { + Text(closure?.consents?.inspector.orEmpty(), style = MaterialTheme.typography.bodySmall) + SignaturePad( + label = "Firmá como inspector/a", + enabled = !model.busy, + onCaptured = { file -> signWithGeo(file, company = false) }, + ) + } + + HorizontalDivider() + Text("Recepción de la empresa", fontWeight = FontWeight.Bold) + if (companyOutcome != null) { + val detail = when (companyOutcome.status) { + "SIGNED" -> if (companyOutcome.companyManifestation == "DISSENT") "Firma en disidencia" else "Firma registrada" + "REFUSED" -> "Negativa a firmar" + "ABSENT" -> "Responsable ausente" + else -> companyOutcome.status + } + Text("✓ $detail", color = MaterialTheme.colorScheme.primary) + companyOutcome.reason?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + companyOutcome.companyStatement?.let { Text(it, style = MaterialTheme.typography.bodySmall) } + } else if (closure?.responsible?.attendanceStatus == "ABSENT") { + Text("El responsable fue registrado como ausente.") + Button( + onClick = { + model.recordCompanyOutcome( + "ABSENT", + closure.responsible.absenceReason ?: "Responsable de empresa ausente durante la inspección", + ) + }, + enabled = !model.busy && inspectorSigned, + modifier = Modifier.fillMaxWidth(), + ) { Text("Asentar ausencia en el Acta") } + } else { + Text(closure?.consents?.company.orEmpty(), style = MaterialTheme.typography.bodySmall) + Row(Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) { + AssistChip(onClick = { manifestation = "CONFORMITY" }, label = { Text(if (manifestation == "CONFORMITY") "✓ Conforme" else "Conforme") }) + AssistChip(onClick = { manifestation = "DISSENT" }, label = { Text(if (manifestation == "DISSENT") "✓ En disidencia" else "En disidencia") }) + } + if (manifestation == "DISSENT") { + OutlinedTextField( + dissentStatement, + { dissentStatement = it }, + label = { Text("Manifestación de disidencia *") }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + } + SignaturePad( + label = "Firma del responsable de empresa", + enabled = !model.busy && inspectorSigned && (manifestation != "DISSENT" || dissentStatement.trim().length >= 10), + onCaptured = { file -> signWithGeo(file, company = true) }, + ) + Text("Si la persona presente se niega a firmar, asentá el motivo en lugar de dibujar una firma.", style = MaterialTheme.typography.bodySmall) + OutlinedTextField( + refusalReason, + { refusalReason = it }, + label = { Text("Motivo de negativa") }, + minLines = 2, + modifier = Modifier.fillMaxWidth(), + ) + OutlinedButton( + onClick = { model.recordCompanyOutcome("REFUSED", refusalReason) }, + enabled = !model.busy && inspectorSigned && refusalReason.trim().length >= 10, + modifier = Modifier.fillMaxWidth(), + ) { Text("Registrar negativa a firmar") } + } + + HorizontalDivider() + if (inspectorSigned && companyOutcome != null) { + Button( + onClick = { model.closeSelectedAct() }, + enabled = !model.busy, + modifier = Modifier.fillMaxWidth(), + ) { Text("Cerrar Acta definitivamente") } + } else if (inspectorSigned) { + Text( + "La inspección puede finalizar con la firma de empresa pendiente; el Acta permanecerá preparada hasta registrar firma, negativa o ausencia.", + style = MaterialTheme.typography.bodySmall, + ) + } + } + + "CLOSED" -> { + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), + ) { + Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(5.dp)) { + Text("Acta cerrada e inmutable", fontWeight = FontWeight.Bold) + Text("El PDF, informe Word y entregas documentales se generan desde este cierre.") + closure?.closure?.finalSha256?.let { Text("SHA-256: $it", style = MaterialTheme.typography.bodySmall) } + } + } + } + + "CANCELLED" -> Text("Esta Acta fue cancelada y permanece sólo como antecedente.") + } + } + + if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) { + HorizontalDivider() + val drafts = model.acts.count { it.status == "DRAFT" } + val readyMissingInspector = model.acts.any { summary -> + if (summary.status != "READY") false + else if (selected?.id == summary.id) { + model.actClosure?.signatures?.none { it.signerType == "INSPECTOR" && it.status == "SIGNED" } ?: true + } else true + } + Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("No puede quedar ninguna Acta en borrador. Cada Acta preparada debe tener firma de inspector.") + Button( + onClick = { model.closeInspection() }, + enabled = !model.busy && drafts == 0 && !readyMissingInspector, + modifier = Modifier.fillMaxWidth(), + ) { Text("Cerrar inspección y salir de la empresa") } + } + } +} + +@Composable +private fun F32ActMessage(model: MainViewModel) { + val text = model.error ?: model.notice ?: return + Card( + Modifier.fillMaxWidth(), + colors = CardDefaults.cardColors( + containerColor = if (model.error != null) MaterialTheme.colorScheme.errorContainer + else MaterialTheme.colorScheme.secondaryContainer, + ), + onClick = { model.clearMessages() }, + ) { + Text(text, Modifier.padding(12.dp)) + } +} + +private fun actStatusLabel(status: String): String = when (status) { + "DRAFT" -> "Borrador" + "READY" -> "Preparada para firmas" + "CLOSED" -> "Cerrada" + "CANCELLED" -> "Cancelada" + else -> status +} + +private fun hasActLocation(context: Context): Boolean = + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_FINE_LOCATION) == PackageManager.PERMISSION_GRANTED || + ContextCompat.checkSelfPermission(context, Manifest.permission.ACCESS_COARSE_LOCATION) == PackageManager.PERMISSION_GRANTED + +private suspend fun currentActSignatureGeo(context: Context): ActSignatureGeo = suspendCancellableCoroutine { continuation -> + if (!hasActLocation(context)) { + continuation.resumeWithException(SecurityException("Ubicación no autorizada")) + return@suspendCancellableCoroutine + } + val source = CancellationTokenSource() + LocationServices.getFusedLocationProviderClient(context) + .getCurrentLocation(Priority.PRIORITY_HIGH_ACCURACY, source.token) + .addOnSuccessListener { location -> + if (!continuation.isActive) return@addOnSuccessListener + if (location == null) continuation.resumeWithException(IllegalStateException("Ubicación no disponible")) + else continuation.resume(ActSignatureGeo(location.latitude, location.longitude, location.accuracy.toDouble())) + } + .addOnFailureListener { if (continuation.isActive) continuation.resumeWithException(it) } + continuation.invokeOnCancellation { source.cancel() } +} From a1450da51e94ab8450093d1db5a72727889a6e71 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:23:10 -0300 Subject: [PATCH 11/22] =?UTF-8?q?F3.2=20Android:=20integrar=20Actas=20en?= =?UTF-8?q?=20la=20navegaci=C3=B3n=20de=20inspecci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../korexlabs/dhinspeccion/ui/F3VisitRoot.kt | 36 +++++++++++++++---- 1 file changed, 30 insertions(+), 6 deletions(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt index 8552440..2ca8670 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/F3VisitRoot.kt @@ -76,15 +76,31 @@ private data class F3GeoSnapshot( @Composable fun F3VisitRoot(model: MainViewModel) { var inventoryMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) } - if (inventoryMode) { - F3FieldInventoryScreen(model, onBack = { inventoryMode = false }) - } else { - F3VisitOverview(model, onInventory = { inventoryMode = true }) + var actsMode by rememberSaveable(model.visit?.id) { mutableStateOf(false) } + when { + actsMode -> MobileActsScreen( + model = model, + onBack = { actsMode = false }, + onGoInventory = { + actsMode = false + inventoryMode = true + }, + ) + inventoryMode -> F3FieldInventoryScreen(model, onBack = { inventoryMode = false }) + else -> F3VisitOverview( + model = model, + onInventory = { inventoryMode = true }, + onActs = { actsMode = true }, + ) } } @Composable -private fun F3VisitOverview(model: MainViewModel, onInventory: () -> Unit) { +private fun F3VisitOverview( + model: MainViewModel, + onInventory: () -> Unit, + onActs: () -> Unit, +) { val visit = model.visit ?: return Column( Modifier @@ -137,9 +153,17 @@ private fun F3VisitOverview(model: MainViewModel, onInventory: () -> Unit) { Button(onClick = onInventory, modifier = Modifier.fillMaxWidth()) { Text("Abrir Inventario de campo") } + OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { + val open = model.acts.count { it.status == "DRAFT" || it.status == "READY" } + Text("Actas de la inspección · ${model.acts.size}${if (open > 0) " · $open abiertas" else ""}") + } + } else if (visit.status == "CLOSED") { + OutlinedButton(onClick = onActs, modifier = Modifier.fillMaxWidth()) { + Text("Ver Actas · ${model.acts.size}") + } } else if (visit.status == "PLANNED") { Text( - "Primero iniciá la inspección para habilitar altas, fotografías y Hallazgos.", + "Primero iniciá la inspección para habilitar altas, fotografías, Actas y Hallazgos.", style = MaterialTheme.typography.bodySmall, ) } From 9e5096708933b0961a167fd17134d44d2b374cfc Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:24:16 -0300 Subject: [PATCH 12/22] F3.2 Android: versionar APK 0.12.0 --- android-app/app/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/android-app/app/build.gradle.kts b/android-app/app/build.gradle.kts index fa5b433..44e2be3 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 = 18 - versionName = "0.11.0" + versionCode = 19 + versionName = "0.12.0" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" vectorDrawables.useSupportLibrary = true From d49fb81bcf036d706044b39085c7b9b66eeb9007 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:24:31 -0300 Subject: [PATCH 13/22] F3.2 Android: activar CI y artefacto 0.12.0 --- .github/workflows/android.yml | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/android.yml b/.github/workflows/android.yml index da67567..6364f37 100644 --- a/.github/workflows/android.yml +++ b/.github/workflows/android.yml @@ -1,5 +1,5 @@ name: Android APK -# F3.1: este workflow genera la APK debug verificable de la rama antes de promoverla. +# F3.2: genera la APK debug verificable antes de promover la integración multi-Acta. on: push: @@ -8,6 +8,7 @@ on: - 'feature/f2-3*' - 'feature/f2-4*' - 'feature/f3-1*' + - 'feature/f3-2*' paths: - 'android-app/**' - '.github/workflows/android.yml' @@ -57,7 +58,7 @@ jobs: - name: Upload APK uses: actions/upload-artifact@v4 with: - name: DH-Inspeccion-F3.1-0.11.0-debug + name: DH-Inspeccion-F3.2-0.12.0-debug path: android-app/app/build/outputs/apk/debug/app-debug.apk if-no-files-found: error retention-days: 14 From 80ce9ced0ffce1083a23ab77f93da452a9e196ff Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:24:42 -0300 Subject: [PATCH 14/22] =?UTF-8?q?F3.2=20API:=20versionar=20integraci=C3=B3?= =?UTF-8?q?n=20multi-Acta=20m=C3=B3vil?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-v3/src/version.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/api-v3/src/version.ts b/api-v3/src/version.ts index 0a72657..d33ca8d 100644 --- a/api-v3/src/version.ts +++ b/api-v3/src/version.ts @@ -1,2 +1,2 @@ -export const API_VERSION = '0.24.0-1'; -export const API_PHASE = 'F3.1'; +export const API_VERSION = '0.25.0-1'; +export const API_PHASE = 'F3.2'; From 2e5183cc3ceba50c084704260550067c9ba6e75b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:24:57 -0300 Subject: [PATCH 15/22] =?UTF-8?q?F3.2=20API:=20actualizar=20versi=C3=B3n?= =?UTF-8?q?=20de=20paquete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-v3/package.json | 29 +++++++++++++++++++---------- 1 file changed, 19 insertions(+), 10 deletions(-) diff --git a/api-v3/package.json b/api-v3/package.json index 0e8b536..eb8fe0a 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -1,6 +1,6 @@ { "name": "dhv2-api", - "version": "0.24.0-1", + "version": "0.25.0-1", "private": true, "license": "UNLICENSED", "scripts": { @@ -28,19 +28,28 @@ "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", "helmet": "^8.0.0", - "pg": "^8.0.0", + "joi": "^17.13.3", + "jsonwebtoken": "^9.0.2", + "multer": "^2.0.2", + "nodemailer": "^7.0.6", + "pg": "^8.16.3", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.0", - "typeorm": "^0.3.0" + "rxjs": "^7.8.2", + "typeorm": "^0.3.28" }, "devDependencies": { - "@nestjs/cli": "^11.0.0", - "@nestjs/schematics": "^11.0.0", + "@nestjs/cli": "^11.0.10", + "@nestjs/schematics": "^11.0.7", + "@nestjs/testing": "^11.1.6", "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.3", - "@types/node": "^24.0.0", - "ts-node": "^10.9.2", - "tsx": "^4.20.6", - "typescript": "^5.9.0" + "@types/jsonwebtoken": "^9.0.10", + "@types/multer": "^2.0.0", + "@types/node": "^24.3.0", + "@types/nodemailer": "^6.4.17", + "@types/supertest": "^6.0.3", + "supertest": "^7.1.4", + "tsx": "^4.20.5", + "typescript": "^5.9.2" } } From a2087884887309e58ace3e1d596eab5de337d2dc Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:25:23 -0300 Subject: [PATCH 16/22] =?UTF-8?q?F3.2:=20agregar=20CI=20de=20integraci?= =?UTF-8?q?=C3=B3n=20multi-Acta?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/f3-2-ci.yml | 80 +++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) create mode 100644 .github/workflows/f3-2-ci.yml diff --git a/.github/workflows/f3-2-ci.yml b/.github/workflows/f3-2-ci.yml new file mode 100644 index 0000000..e690d05 --- /dev/null +++ b/.github/workflows/f3-2-ci.yml @@ -0,0 +1,80 @@ +name: F3.2 Multi-Acta CI + +on: + push: + branches: + - 'feature/f3-2*' + workflow_dispatch: + +permissions: + contents: read + +jobs: + api: + name: API · F3.2 + runs-on: ubuntu-latest + timeout-minutes: 25 + defaults: + run: + working-directory: api-v3 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: api-v3/package-lock.json + - run: npm ci + - name: Typecheck + run: npm run typecheck + - name: Tests + run: npm test + - name: Build + run: npm run build + + web: + name: WEB · regression + runs-on: ubuntu-latest + timeout-minutes: 20 + defaults: + run: + working-directory: web-v2 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: '24' + cache: npm + cache-dependency-path: web-v2/package-lock.json + - run: npm ci + - run: npm run typecheck + - name: F3.1 structural WEB contract + run: bash ../scripts/check-f3-1-web-contract.sh + - run: npm run build + + deploy-preflight: + name: VPS-equivalent preflight / Docker + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [api, web] + steps: + - uses: actions/checkout@v4 + - name: Validate shell scripts + run: | + while IFS= read -r -d '' script; do + bash -n "$script" + done < <(find scripts -type f -name '*.sh' -print0) + - name: Validate Compose + run: docker compose --env-file .env.example config >/dev/null + - name: VPS-equivalent isolated API tests + run: | + set -Eeuo pipefail + image="dhv2-api:f3-2-preflight-${GITHUB_SHA::12}" + docker build --target builder -t "$image" api-v3 + docker run --rm \ + -v "$PWD/api-v3/test:/app/test:ro" \ + -v "$PWD/api-v3/tsconfig.test.json:/app/tsconfig.test.json:ro" \ + "$image" npm test + docker image rm "$image" >/dev/null 2>&1 || true + - name: Build production images + run: docker compose --env-file .env.example build api migrate web From 5f3987747de3c48038cc7ccdf19c3b6df42e5605 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:26:32 -0300 Subject: [PATCH 17/22] =?UTF-8?q?F3.2=20API:=20conservar=20dependencias=20?= =?UTF-8?q?y=20cambiar=20s=C3=B3lo=20versi=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-v3/package.json | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/api-v3/package.json b/api-v3/package.json index eb8fe0a..00ee20b 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -18,7 +18,7 @@ "dependencies": { "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", - "@nestjs/core": "^11.0.0", + "@nestjs/core": "^11.0.2", "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.0", "@nestjs/throttler": "^6.5.0", @@ -28,28 +28,19 @@ "class-validator": "^0.14.2", "cookie-parser": "^1.4.7", "helmet": "^8.0.0", - "joi": "^17.13.3", - "jsonwebtoken": "^9.0.2", - "multer": "^2.0.2", - "nodemailer": "^7.0.6", - "pg": "^8.16.3", + "pg": "^8.0.0", "reflect-metadata": "^0.2.2", - "rxjs": "^7.8.2", - "typeorm": "^0.3.28" + "rxjs": "^7.8.0", + "typeorm": "^0.3.0" }, "devDependencies": { - "@nestjs/cli": "^11.0.10", - "@nestjs/schematics": "^11.0.7", - "@nestjs/testing": "^11.1.6", + "@nestjs/cli": "^11.0.0", + "@nestjs/schematics": "^11.0.0", "@types/cookie-parser": "^1.4.9", "@types/express": "^5.0.3", - "@types/jsonwebtoken": "^9.0.10", - "@types/multer": "^2.0.0", - "@types/node": "^24.3.0", - "@types/nodemailer": "^6.4.17", - "@types/supertest": "^6.0.3", - "supertest": "^7.1.4", - "tsx": "^4.20.5", - "typescript": "^5.9.2" + "@types/node": "^24.0.0", + "ts-node": "^10.9.2", + "tsx": "^4.20.6", + "typescript": "^5.9.0" } } From bd0f22969c81936549621a6b4d59eb2c52db6697 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:26:48 -0300 Subject: [PATCH 18/22] =?UTF-8?q?F3.2=20API:=20restaurar=20manifiesto=20ex?= =?UTF-8?q?acto=20de=20producci=C3=B3n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- api-v3/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/api-v3/package.json b/api-v3/package.json index 00ee20b..674c7ff 100644 --- a/api-v3/package.json +++ b/api-v3/package.json @@ -18,7 +18,7 @@ "dependencies": { "@nestjs/common": "^11.0.0", "@nestjs/config": "^4.0.0", - "@nestjs/core": "^11.0.2", + "@nestjs/core": "^11.0.0", "@nestjs/jwt": "^11.0.2", "@nestjs/platform-express": "^11.0.0", "@nestjs/throttler": "^6.5.0", From ecf6f71c586b14b13245a9d4877de850a5d6cb0b Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:34:33 -0300 Subject: [PATCH 19/22] F3.2 Android: corregir API de PointerInput en firma --- .../src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt index ad1150c..97b7318 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/SignaturePad.kt @@ -27,7 +27,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.StrokeCap -import androidx.compose.ui.input.pointer.consume import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.layout.onSizeChanged import androidx.compose.ui.platform.LocalContext From 3e5dfb78a221d11591e55d683b1d6643fa712025 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:35:37 -0300 Subject: [PATCH 20/22] F3.2 Android: corregir Card clickeable de mensajes --- .../main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt index 783478b..8b3ea37 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -382,7 +382,7 @@ fun MobileActsScreen( private fun F32ActMessage(model: MainViewModel) { val text = model.error ?: model.notice ?: return Card( - Modifier.fillMaxWidth(), + modifier = Modifier.fillMaxWidth(), colors = CardDefaults.cardColors( containerColor = if (model.error != null) MaterialTheme.colorScheme.errorContainer else MaterialTheme.colorScheme.secondaryContainer, From 2d4e8ae2a7eb0f7aa73bd067ed167cfce29810e6 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:43:24 -0300 Subject: [PATCH 21/22] F3.2 Android: permitir Actas READY paralelas y delegar cierre al servidor --- .../dhinspeccion/ui/MobileActsScreen.kt | 22 ++++++++++--------- 1 file changed, 12 insertions(+), 10 deletions(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt index 8b3ea37..d1e6722 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -148,14 +148,20 @@ fun MobileActsScreen( } } - val hasOpenAct = model.acts.any { it.status == "DRAFT" || it.status == "READY" } - if (visit.status == "IN_PROGRESS" && !hasOpenAct) { + val hasDraftAct = model.acts.any { it.status == "DRAFT" } + if (visit.status == "IN_PROGRESS" && !hasDraftAct) { Card( Modifier.fillMaxWidth(), colors = CardDefaults.cardColors(containerColor = MaterialTheme.colorScheme.primaryContainer), ) { Column(Modifier.padding(12.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) { Text("Nueva Acta", fontWeight = FontWeight.Bold) + if (model.acts.any { it.status == "READY" }) { + Text( + "Puede existir una nueva Acta en borrador aunque haya Actas preparadas pendientes de firma de empresa. Sólo se permite un borrador a la vez.", + style = MaterialTheme.typography.bodySmall, + ) + } val selectedInventory = model.selectedFieldAsset?.asset if (selectedInventory == null) { Text("Primero elegí una Instalación o Subinstalación desde Inventario de campo. Ese registro será el primer elemento del Acta.") @@ -361,17 +367,13 @@ fun MobileActsScreen( if (visit.status == "IN_PROGRESS" && model.acts.isNotEmpty()) { HorizontalDivider() val drafts = model.acts.count { it.status == "DRAFT" } - val readyMissingInspector = model.acts.any { summary -> - if (summary.status != "READY") false - else if (selected?.id == summary.id) { - model.actClosure?.signatures?.none { it.signerType == "INSPECTOR" && it.status == "SIGNED" } ?: true - } else true - } Text("Finalizar inspección", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("No puede quedar ninguna Acta en borrador. Cada Acta preparada debe tener firma de inspector.") + Text( + "No puede quedar ninguna Acta en borrador. Al cerrar, el servidor verifica que cada Acta preparada tenga firma de inspector; si falta alguna, indicará cuál debe completarse.", + ) Button( onClick = { model.closeInspection() }, - enabled = !model.busy && drafts == 0 && !readyMissingInspector, + enabled = !model.busy && drafts == 0, modifier = Modifier.fillMaxWidth(), ) { Text("Cerrar inspección y salir de la empresa") } } From 03a3a66c51daebfead07c2a20d483e3af5ae2a01 Mon Sep 17 00:00:00 2001 From: enlineawork Date: Mon, 7 Sep 2026 16:50:17 -0300 Subject: [PATCH 22/22] =?UTF-8?q?F3.2=20Android:=20permitir=20preparar=20A?= =?UTF-8?q?ctas=20de=20verificaci=C3=B3n=20sin=20Hallazgos=20nuevos?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt index d1e6722..6d2a2dc 100644 --- a/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt +++ b/android-app/app/src/main/java/com/korexlabs/dhinspeccion/ui/MobileActsScreen.kt @@ -233,16 +233,16 @@ fun MobileActsScreen( } HorizontalDivider() - Text("2. Hallazgos", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita.") + Text("2. Hallazgos / verificaciones", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) + Text("Los Hallazgos se cargan desde Inventario y quedan vinculados a ${selected.code} de forma explícita. Un Acta de verificación puede prepararse sin Hallazgos nuevos si la verificación ya fue registrada.") Button(onClick = onGoInventory, modifier = Modifier.fillMaxWidth()) { Text("Ir a Inventario / Hallazgos") } HorizontalDivider() Text("3. Preparar Acta", style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold) - Text("Al preparar, el contenido se congela y se calcula su hash. Después sólo se admiten firmas y resultado de empresa.") + Text("Al preparar, el contenido se congela y se calcula su hash. El servidor exige al menos un Hallazgo o una verificación registrada.") Button( onClick = { model.prepareSelectedAct() }, - enabled = !model.busy && closure?.responsible != null && selected.findingCount > 0, + enabled = !model.busy && closure?.responsible != null, modifier = Modifier.fillMaxWidth(), ) { Text("Preparar Acta para firmas") } }