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 + } + } +}