F3.2 Android: agregar cliente de Actas y cierre

This commit is contained in:
2026-09-07 16:14:43 -03:00
parent 28f6fb3684
commit 9f60e671d2
@@ -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<AssetSummary> = 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<MobileActSummary> = emptyList(),
val meta: MobileActListMeta = MobileActListMeta(),
)
data class CreateMobileActRequest(
val occurredAt: String,
val title: String,
val summary: String,
val observations: String? = null,
val assetIds: List<String>,
)
data class UpdateMobileActRequest(
val assetIds: List<String>,
)
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<MobileSignature> = 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 <T> 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
}
}
}