diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt index 2f065e1..c449b0a 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionDao.kt @@ -14,6 +14,30 @@ interface RouteActionDao { @Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1") suspend fun find(clientActionId: String): RouteActionEntity? + @Query( + """ + SELECT * FROM route_actions + WHERE routeId = :routeId + AND action = 'start' + AND createdAtEpochMillis < :beforeEpochMillis + ORDER BY createdAtEpochMillis DESC + LIMIT 1 + """, + ) + suspend fun latestStartBefore(routeId: String, beforeEpochMillis: Long): RouteActionEntity? + + @Query( + """ + SELECT * FROM route_actions + WHERE routeId = :routeId + AND action = 'finish' + AND createdAtEpochMillis > :afterEpochMillis + AND status != 'CONFIRMED' + ORDER BY createdAtEpochMillis ASC + """, + ) + suspend fun unconfirmedFinishesAfter(routeId: String, afterEpochMillis: Long): List + @Query("SELECT * FROM route_actions WHERE status != 'CONFIRMED'") suspend fun allUnsent(): List diff --git a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt index 5fc0be0..09744f2 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/data/upload/RouteActionWorker.kt @@ -8,9 +8,11 @@ import pl.firmatpp.kierowca.data.ApiErrorKind import pl.firmatpp.kierowca.data.ApiErrorMapper import pl.firmatpp.kierowca.data.DriverRepository import pl.firmatpp.kierowca.data.rethrowIfCancellation +import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.FinishRouteBody import pl.firmatpp.kierowca.data.model.RouteActionResponse import pl.firmatpp.kierowca.data.model.StartRouteBody +import pl.firmatpp.kierowca.data.model.driverLifecycleStatus import pl.firmatpp.kierowca.data.sync.DriverSyncRepository import pl.firmatpp.kierowca.data.sync.NetworkMonitor import pl.firmatpp.kierowca.diagnostics.AppDiagnostics @@ -31,6 +33,32 @@ class RouteActionWorker( if (!NetworkMonitor(applicationContext).isCurrentlyValidated()) return Result.retry() val clientActionId = inputData.getString(KEY_CLIENT_ACTION_ID) ?: return Result.failure() val action = dao.find(clientActionId) ?: return Result.failure() + val startPrerequisite = if (action.action == RouteActionType.Finish) { + dao.latestStartBefore(action.routeId, action.createdAtEpochMillis) + } else { + null + } + when (routeActionPrerequisiteState(action, startPrerequisite)) { + RouteActionPrerequisiteState.Waiting -> { + dao.updateStatus( + clientActionId = clientActionId, + status = RouteActionStatus.Pending, + lastError = "Czekam na potwierdzenie rozpoczęcia kursu.", + attemptIncrement = 0, + ) + return Result.retry() + } + RouteActionPrerequisiteState.Failed -> { + dao.updateStatus( + clientActionId = clientActionId, + status = RouteActionStatus.FailedConflict, + lastError = "Najpierw trzeba potwierdzić rozpoczęcie kursu.", + attemptIncrement = 0, + ) + return Result.failure() + } + RouteActionPrerequisiteState.Ready -> Unit + } val photoClientRequestIds = photoClientRequestIds(action) val photoDependencies = photoClientRequestIds.mapNotNull { photoDao.find(it) } @@ -74,10 +102,20 @@ class RouteActionWorker( ) syncRepository.cacheConfirmedRoute(response.route, response.route.routeDate) DriverSyncWorker.enqueue(applicationContext, response.route.routeDate, response.route.id) + if (action.action == RouteActionType.Start) { + val outbox = RouteActionOutbox(applicationContext) + dao.unconfirmedFinishesAfter(action.routeId, action.createdAtEpochMillis) + .forEach { outbox.enqueueWorker(it.clientActionId) } + } Result.success() }.getOrElse { throwable -> throwable.rethrowIfCancellation() val error = ApiErrorMapper.map(throwable) + val serverRoute = if (error.kind == ApiErrorKind.Conflict) { + runCatching { repository.route(action.routeId).route }.getOrNull() + } else { + null + } AppDiagnostics.reportNonFatal( throwable = throwable, operation = "route_action_worker", @@ -89,8 +127,20 @@ class RouteActionWorker( "api_error_code" to error.code, "api_status_code" to error.statusCode, "retryable" to error.retryable, + "server_route_status" to serverRoute?.driverLifecycleStatus(), ), ) + if (serverRoute != null && routeActionMatchesServer(action, photoClientRequestIds, serverRoute)) { + dao.updateStatus( + clientActionId = clientActionId, + status = RouteActionStatus.Confirmed, + lastError = null, + attemptIncrement = 0, + ) + syncRepository.cacheConfirmedRoute(serverRoute, serverRoute.routeDate) + DriverSyncWorker.enqueue(applicationContext, serverRoute.routeDate, serverRoute.id) + return@getOrElse Result.success() + } val status = when { error.kind == ApiErrorKind.Conflict -> RouteActionStatus.FailedConflict error.retryable -> RouteActionStatus.FailedRetryable @@ -99,7 +149,7 @@ class RouteActionWorker( dao.updateStatus( clientActionId = clientActionId, status = status, - lastError = error.message, + lastError = routeActionConflictMessage(error.message, serverRoute), attemptIncrement = 0, ) @@ -146,3 +196,75 @@ class RouteActionWorker( fun uniqueWorkName(clientActionId: String): String = "route-action-$clientActionId" } } + +internal fun routeActionMatchesServer( + action: RouteActionEntity, + photoClientRequestIds: List, + serverRoute: DriverRouteDto, +): Boolean { + val lifecycleMatches = when (action.action) { + RouteActionType.Start -> serverRoute.driverLifecycleStatus() in setOf("W TRAKCIE", "ZAKOŃCZONA") + RouteActionType.Finish -> serverRoute.driverLifecycleStatus() == "ZAKOŃCZONA" + else -> false + } + if (!lifecycleMatches) return false + + val serverWeight = when (action.action) { + RouteActionType.Start -> serverRoute.loadingWeight + RouteActionType.Finish -> serverRoute.unloadingWeight + else -> null + } + val serverNotes = when (action.action) { + RouteActionType.Start -> serverRoute.loadingNotes + RouteActionType.Finish -> serverRoute.unloadingNotes + else -> null + } + val weightMatches = when { + action.weight == null && serverWeight == null -> true + action.weight == null || serverWeight == null -> false + else -> kotlin.math.abs(action.weight - serverWeight) < 0.0005 + } + val notesMatch = action.notes.normalizedRouteActionNotes() == serverNotes.normalizedRouteActionNotes() + val serverPhotoRequestIds = serverRoute.photos + .mapNotNull { it.clientRequestId?.trim()?.takeIf(String::isNotBlank) } + .toSet() + val photosMatch = photoClientRequestIds.all(serverPhotoRequestIds::contains) + + return weightMatches && notesMatch && photosMatch +} + +internal fun routeActionConflictMessage(message: String, serverRoute: DriverRouteDto?): String { + val serverStatus = serverRoute?.driverLifecycleStatus() ?: return message + val operationalStatus = serverRoute.operationalStatus + ?.takeIf(String::isNotBlank) + ?.let { ", status operacyjny: $it" } + .orEmpty() + + return "$message Status kursu na serwerze: $serverStatus$operationalStatus. Dane w telefonie pozostają zachowane." +} + +private fun String?.normalizedRouteActionNotes(): String? = + this?.trim()?.takeIf(String::isNotEmpty) + +internal enum class RouteActionPrerequisiteState { + Ready, + Waiting, + Failed, +} + +internal fun routeActionPrerequisiteState( + action: RouteActionEntity, + precedingStart: RouteActionEntity?, +): RouteActionPrerequisiteState { + if (action.action != RouteActionType.Finish || precedingStart == null) { + return RouteActionPrerequisiteState.Ready + } + + return when (precedingStart.status) { + RouteActionStatus.Confirmed -> RouteActionPrerequisiteState.Ready + RouteActionStatus.FailedConflict, + RouteActionStatus.FailedPermanent, + -> RouteActionPrerequisiteState.Failed + else -> RouteActionPrerequisiteState.Waiting + } +} diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt index e0c37a8..cb98e98 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverApp.kt @@ -448,6 +448,7 @@ fun DriverApp( onRefresh = viewModel::refreshSelectedRoute, onStartRoute = openStartRoute, onFinishRoute = viewModel::openFinishRoute, + onRetryRouteAction = viewModel::retryConflictedRouteAction, onCorrectRouteAction = viewModel::correctFailedRouteAction, ) DriverScreen.StartRoute -> RouteStageScreen( @@ -3567,6 +3568,7 @@ private fun DetailScreen( onRefresh: () -> Unit, onStartRoute: () -> Unit, onFinishRoute: () -> Unit, + onRetryRouteAction: () -> Unit, onCorrectRouteAction: () -> Unit, ) { val context = LocalContext.current @@ -3608,6 +3610,7 @@ private fun DetailScreen( onRefresh = onRefresh, onStart = onStartRoute, onFinish = onFinishRoute, + onRetry = onRetryRouteAction, onCorrect = onCorrectRouteAction, ) } @@ -3673,6 +3676,7 @@ private fun RouteLifecycleSection( onRefresh: () -> Unit, onStart: () -> Unit, onFinish: () -> Unit, + onRetry: () -> Unit, onCorrect: () -> Unit, ) { val steps = routeFlowSteps(route, routeActions, loadingPhotoRequirement, loadingWeightRequirement) @@ -3708,8 +3712,16 @@ private fun RouteLifecycleSection( container = if (callout.isConflict) TppTheme.colors.warningContainer else TppTheme.colors.panel, outline = if (callout.isConflict) TppTheme.colors.warningOutline else TppTheme.colors.outline, color = if (callout.isConflict) TppTheme.colors.error else TppTheme.colors.muted, - actionLabel = if (callout.canCorrect) "Popraw dane" else "Odśwież dane", - onAction = if (callout.canCorrect) onCorrect else onRefresh, + actionLabel = when { + callout.canCorrect -> "Popraw dane" + callout.isConflict -> "Ponów wysyłanie" + else -> "Odśwież dane" + }, + onAction = when { + callout.canCorrect -> onCorrect + callout.isConflict -> onRetry + else -> onRefresh + }, ) } diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt index 3bd3579..7699afd 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverRouteProjection.kt @@ -22,13 +22,20 @@ internal val routeActionVisibleStatuses = setOf( RouteActionStatus.FailedPermanent, ) -private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed +private val routeActionDataProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed +private val routeActionLifecycleProjectionStatuses = setOf( + RouteActionStatus.Pending, + RouteActionStatus.WaitingForPhotos, + RouteActionStatus.Syncing, + RouteActionStatus.FailedRetryable, + RouteActionStatus.Confirmed, +) fun projectDriverRoute(route: DriverRouteDto, actions: List): ProjectedDriverRoute { val visible = actions .filter { it.routeId == route.id && it.status in routeActionVisibleStatuses } val projectable = actions - .filter { it.routeId == route.id && it.status in routeActionProjectionStatuses } + .filter { it.routeId == route.id && it.status in routeActionDataProjectionStatuses } var projectedRoute = route var loadingWeightPending = false var unloadingWeightPending = false @@ -39,25 +46,33 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List): when (action.action) { RouteActionType.Start -> { projectedRoute = projectedRoute.copy( - status = "W TRAKCIE", - driverStatus = "W TRAKCIE", loadingWeight = action.weight, loadingNotes = action.notes, - trackingStatus = "active", photos = projectedRoute.safePhotos(), ) + if (action.status in routeActionLifecycleProjectionStatuses) { + projectedRoute = projectedRoute.copy( + status = "W TRAKCIE", + driverStatus = "W TRAKCIE", + trackingStatus = "active", + ) + } loadingWeightPending = action.status != RouteActionStatus.Confirmed } RouteActionType.Finish -> { projectedRoute = projectedRoute.copy( - status = "ZAKOŃCZONA", - driverStatus = "ZAKOŃCZONA", unloadingWeight = action.weight, unloadingNotes = action.notes, - trackingStatus = "finished", - completedAt = action.occurredAt, photos = projectedRoute.safePhotos(), ) + if (action.status in routeActionLifecycleProjectionStatuses) { + projectedRoute = projectedRoute.copy( + status = "ZAKOŃCZONA", + driverStatus = "ZAKOŃCZONA", + trackingStatus = "finished", + completedAt = action.occurredAt, + ) + } unloadingWeightPending = action.status != RouteActionStatus.Confirmed } } diff --git a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt index b9f5e81..eeb9598 100644 --- a/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt +++ b/app/src/main/java/pl/firmatpp/kierowca/ui/DriverViewModel.kt @@ -1268,6 +1268,30 @@ class DriverViewModel(application: Application) : AndroidViewModel(application) } } + fun retryConflictedRouteAction() { + val snapshot = _state.value + val action = (snapshot.visibleRouteActions + snapshot.routeActions) + .distinctBy { it.clientActionId } + .filter { it.status == RouteActionStatus.FailedConflict } + .maxByOrNull { it.createdAtEpochMillis } + ?: return + + viewModelScope.launch { + runCatching { + routeActionOutbox.retry(action.clientActionId) + } + .onSuccess { + _state.update { + it.copy( + feedback = "Ponawiam wysyłanie zapisanych danych.", + error = null, + ) + } + } + .onFailure { throwable -> _state.update { it.withApiError(throwable) } } + } + } + fun submitStartRoute() { submitRouteStage(stage = "loading") } diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/upload/RouteActionPrerequisiteTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/upload/RouteActionPrerequisiteTest.kt new file mode 100644 index 0000000..beb5bd7 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/upload/RouteActionPrerequisiteTest.kt @@ -0,0 +1,61 @@ +package pl.firmatpp.kierowca.data.upload + +import org.junit.Assert.assertEquals +import org.junit.Test + +class RouteActionPrerequisiteTest { + @Test + fun finishWaitsUntilEarlierStartIsConfirmed() { + val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000) + val pendingStart = action(RouteActionType.Start, RouteActionStatus.Syncing, createdAt = 1_000) + + assertEquals( + RouteActionPrerequisiteState.Waiting, + routeActionPrerequisiteState(finish, pendingStart), + ) + } + + @Test + fun finishRunsAfterEarlierStartIsConfirmed() { + val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000) + val confirmedStart = action(RouteActionType.Start, RouteActionStatus.Confirmed, createdAt = 1_000) + + assertEquals( + RouteActionPrerequisiteState.Ready, + routeActionPrerequisiteState(finish, confirmedStart), + ) + } + + @Test + fun failedStartBlocksFinishInsteadOfSendingItOutOfOrder() { + val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000) + val failedStart = action(RouteActionType.Start, RouteActionStatus.FailedConflict, createdAt = 1_000) + + assertEquals( + RouteActionPrerequisiteState.Failed, + routeActionPrerequisiteState(finish, failedStart), + ) + } + + @Test + fun finishWithoutLocalStartCanUseServerLifecycleState() { + val finish = action(RouteActionType.Finish, RouteActionStatus.Pending, createdAt = 2_000) + + assertEquals( + RouteActionPrerequisiteState.Ready, + routeActionPrerequisiteState(finish, null), + ) + } + + private fun action(type: String, status: String, createdAt: Long): RouteActionEntity = + RouteActionEntity( + clientActionId = "$type-$createdAt", + routeId = "route-1", + action = type, + weight = 12.5, + occurredAt = "2026-07-23T10:00:00Z", + photoClientRequestIdsJson = "[]", + status = status, + createdAtEpochMillis = createdAt, + ) +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/data/upload/RouteActionServerReconciliationTest.kt b/app/src/test/java/pl/firmatpp/kierowca/data/upload/RouteActionServerReconciliationTest.kt new file mode 100644 index 0000000..57d12f3 --- /dev/null +++ b/app/src/test/java/pl/firmatpp/kierowca/data/upload/RouteActionServerReconciliationTest.kt @@ -0,0 +1,105 @@ +package pl.firmatpp.kierowca.data.upload + +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test +import pl.firmatpp.kierowca.data.model.DriverRouteDto +import pl.firmatpp.kierowca.data.model.RoutePhotoDto + +class RouteActionServerReconciliationTest { + @Test + fun matchingFinishedRouteConfirmsDuplicateFinishSafely() { + assertTrue( + routeActionMatchesServer( + action = finish(weight = 11.8, notes = "Uszkodzona plomba."), + photoClientRequestIds = listOf("photo-1"), + serverRoute = route( + status = "ZAKOŃCZONA", + weight = 11.8, + notes = "Uszkodzona plomba.", + photoClientRequestIds = listOf("photo-1"), + ), + ), + ) + } + + @Test + fun differingNotesKeepConflictVisibleAndPreserveLocalData() { + assertFalse( + routeActionMatchesServer( + action = finish(weight = 11.8, notes = "Uwagi z telefonu"), + photoClientRequestIds = listOf("photo-1"), + serverRoute = route( + status = "ZAKOŃCZONA", + weight = 11.8, + notes = "Inna uwaga na serwerze", + photoClientRequestIds = listOf("photo-1"), + ), + ), + ) + } + + @Test + fun missingServerPhotoDoesNotDiscardLocalFinish() { + assertFalse( + routeActionMatchesServer( + action = finish(weight = null, notes = null), + photoClientRequestIds = listOf("photo-1"), + serverRoute = route(status = "ZAKOŃCZONA", weight = null, notes = null), + ), + ) + } + + private fun finish(weight: Double?, notes: String?): RouteActionEntity = + RouteActionEntity( + clientActionId = "finish-1", + routeId = "route-1", + action = RouteActionType.Finish, + weight = weight, + occurredAt = "2026-07-23T10:00:00Z", + photoClientRequestIdsJson = """["photo-1"]""", + notes = notes, + ) + + private fun route( + status: String, + weight: Double?, + notes: String?, + photoClientRequestIds: List = emptyList(), + ): DriverRouteDto = + DriverRouteDto( + id = "route-1", + routeDate = "2026-07-23", + startsAt = "", + originName = "Baza", + destinationName = "Cel", + contractorName = "TPP", + contractName = "Kontrakt", + contractCode = "TPP-1", + relationLabel = "Baza → Cel", + status = status, + driverStatus = status, + unloadingWeight = weight, + unloadingNotes = notes, + distanceKm = 1.0, + notes = null, + truck = null, + photos = photoClientRequestIds.mapIndexed { index, requestId -> + RoutePhotoDto( + id = "photo-$index", + routeId = "route-1", + clientRequestId = requestId, + source = "camera", + stage = "unloading", + mimeType = "image/jpeg", + size = 1, + url = "https://example.test/photo-$index", + takenAt = null, + latitude = null, + longitude = null, + locationAccuracyMeters = null, + createdAt = null, + ) + }, + ) +} diff --git a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt index f75e6a1..a1a22dd 100644 --- a/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt +++ b/app/src/test/java/pl/firmatpp/kierowca/ui/DriverRouteProjectionTest.kt @@ -125,16 +125,25 @@ class DriverRouteProjectionTest { } @Test - fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() { + fun conflictKeepsLocalRouteValuesButPreservesServerLifecycleStatus() { val projection = projectDriverRoute( route = route(status = "W TRAKCIE", loadingWeight = 12.5), - actions = listOf(action(RouteActionType.Finish, RouteActionStatus.FailedConflict, weight = 11.8)), + actions = listOf( + action( + RouteActionType.Finish, + RouteActionStatus.FailedConflict, + weight = 11.8, + notes = "Uszkodzona plomba.", + ), + ), ) val steps = routeFlowSteps(projection.route, projection.visibleActions) val callout = routeSyncCallout(projection.visibleActions) - assertEquals("ZAKOŃCZONA", projection.route.status) + assertEquals("W TRAKCIE", projection.route.status) assertEquals(11.8, projection.route.unloadingWeight) + assertEquals("Uszkodzona plomba.", projection.route.unloadingNotes) + assertTrue(projection.unloadingWeightPending) assertEquals(RouteFlowStepState.NeedsAttention, steps[3].state) assertEquals("Kurs wymaga obsługi. Dane wpisane w telefonie zostały zachowane.", callout?.text) assertTrue(callout?.isConflict ?: false) @@ -148,8 +157,9 @@ class DriverRouteProjectionTest { ) val steps = routeFlowSteps(projection.route, projection.visibleActions) - assertEquals("W TRAKCIE", projection.route.status) + assertEquals("ZAPLANOWANA", projection.route.status) assertEquals(12.5, projection.route.loadingWeight) + assertTrue(projection.loadingWeightPending) assertEquals(RouteFlowStepState.NeedsAttention, steps[1].state) }