F3.2 Android: agregar cliente de Actas y cierre
This commit is contained in:
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user