Compare commits

...
6 Commits
10 changed files with 143 additions and 11 deletions
+2 -2
View File
@@ -34,8 +34,8 @@ android {
applicationId = "pl.firmatpp.kierowca" applicationId = "pl.firmatpp.kierowca"
minSdk = 26 minSdk = 26
targetSdk = 35 targetSdk = 35
versionCode = 47 versionCode = 51
versionName = "1.0.44" versionName = "1.0.45"
setProperty("archivesBaseName", "pl.firmatpp.kierowca") setProperty("archivesBaseName", "pl.firmatpp.kierowca")
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
@@ -14,7 +14,10 @@ class PhotoUploadWorker(
appContext: Context, appContext: Context,
params: WorkerParameters, params: WorkerParameters,
) : CoroutineWorker(appContext, params) { ) : CoroutineWorker(appContext, params) {
private val dao = DriverDatabase.get(appContext).photoUploadDao() private val database = DriverDatabase.get(appContext)
private val dao = database.photoUploadDao()
private val routeActionDao = database.routeActionDao()
private val routeActionOutbox = RouteActionOutbox(appContext)
private val repository = DriverRepository(appContext) private val repository = DriverRepository(appContext)
override suspend fun doWork(): Result { override suspend fun doWork(): Result {
@@ -62,6 +65,7 @@ class PhotoUploadWorker(
} }
dao.markConfirmed(clientRequestId, receipt.serverPhotoId) dao.markConfirmed(clientRequestId, receipt.serverPhotoId)
enqueueRouteActionsWaitingForPhoto(clientRequestId)
Result.success() Result.success()
}.getOrElse { throwable -> }.getOrElse { throwable ->
val error = ApiErrorMapper.map(throwable) val error = ApiErrorMapper.map(throwable)
@@ -90,6 +94,12 @@ class PhotoUploadWorker(
} }
} }
private suspend fun enqueueRouteActionsWaitingForPhoto(clientRequestId: String) {
routeActionDao.waitingForPhotosActions()
.filter { routeActionReferencesPhotoClientRequest(it, clientRequestId) }
.forEach { routeActionOutbox.enqueueWorker(it.clientActionId) }
}
companion object { companion object {
const val KEY_CLIENT_REQUEST_ID = "clientRequestId" const val KEY_CLIENT_REQUEST_ID = "clientRequestId"
const val KEY_PROGRESS = "progress" const val KEY_PROGRESS = "progress"
@@ -14,11 +14,14 @@ interface RouteActionDao {
@Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1") @Query("SELECT * FROM route_actions WHERE clientActionId = :clientActionId LIMIT 1")
suspend fun find(clientActionId: String): RouteActionEntity? suspend fun find(clientActionId: String): RouteActionEntity?
@Query("SELECT * FROM route_actions WHERE status = 'WAITING_FOR_PHOTOS'")
suspend fun waitingForPhotosActions(): List<RouteActionEntity>
@Query( @Query(
""" """
SELECT * FROM route_actions SELECT * FROM route_actions
WHERE routeId = :routeId WHERE routeId = :routeId
AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT') AND status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'CONFIRMED', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
ORDER BY createdAtEpochMillis DESC ORDER BY createdAtEpochMillis DESC
""", """,
) )
@@ -28,6 +31,14 @@ interface RouteActionDao {
""" """
SELECT * FROM route_actions SELECT * FROM route_actions
WHERE status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT') WHERE status IN ('PENDING', 'WAITING_FOR_PHOTOS', 'SYNCING', 'FAILED_RETRYABLE', 'FAILED_CONFLICT', 'FAILED_PERMANENT')
OR clientActionId IN (
SELECT confirmed.clientActionId
FROM route_actions AS confirmed
WHERE confirmed.status = 'CONFIRMED'
AND confirmed.routeId = route_actions.routeId
ORDER BY confirmed.createdAtEpochMillis DESC
LIMIT 2
)
ORDER BY createdAtEpochMillis DESC ORDER BY createdAtEpochMillis DESC
""", """,
) )
@@ -53,7 +53,7 @@ class RouteActionOutbox(
return entity return entity
} }
private fun enqueueWorker(clientActionId: String) { fun enqueueWorker(clientActionId: String) {
val request = OneTimeWorkRequestBuilder<RouteActionWorker>() val request = OneTimeWorkRequestBuilder<RouteActionWorker>()
.setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId)) .setInputData(workDataOf(RouteActionWorker.KEY_CLIENT_ACTION_ID to clientActionId))
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build()) .setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
@@ -0,0 +1,17 @@
package pl.firmatpp.kierowca.data.upload
import com.google.gson.Gson
private val routeActionPhotoDependencyGson = Gson()
fun routeActionReferencesPhotoClientRequest(action: RouteActionEntity, clientRequestId: String): Boolean {
val needle = clientRequestId.trim()
if (needle.isBlank()) return false
return runCatching {
routeActionPhotoDependencyGson
.fromJson(action.photoClientRequestIdsJson, Array<String>::class.java)
?.any { it.trim() == needle }
?: false
}.getOrDefault(false)
}
@@ -1,6 +1,7 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -21,14 +22,18 @@ internal val routeActionVisibleStatuses = setOf(
RouteActionStatus.FailedPermanent, RouteActionStatus.FailedPermanent,
) )
private val routeActionProjectionStatuses = routeActionVisibleStatuses + RouteActionStatus.Confirmed
fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute { fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>): ProjectedDriverRoute {
val visible = actions val visible = actions
.filter { it.routeId == route.id && it.status in routeActionVisibleStatuses } .filter { it.routeId == route.id && it.status in routeActionVisibleStatuses }
val projectable = actions
.filter { it.routeId == route.id && it.status in routeActionProjectionStatuses }
var projectedRoute = route var projectedRoute = route
var loadingWeightPending = false var loadingWeightPending = false
var unloadingWeightPending = false var unloadingWeightPending = false
visible projectable
.sortedWith(compareBy<RouteActionEntity> { it.createdAtEpochMillis }.thenBy { it.clientActionId }) .sortedWith(compareBy<RouteActionEntity> { it.createdAtEpochMillis }.thenBy { it.clientActionId })
.forEach { action -> .forEach { action ->
when (action.action) { when (action.action) {
@@ -37,8 +42,9 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
status = "W TRAKCIE", status = "W TRAKCIE",
loadingWeight = action.weight, loadingWeight = action.weight,
trackingStatus = "active", trackingStatus = "active",
photos = projectedRoute.safePhotos(),
) )
loadingWeightPending = true loadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
RouteActionType.Finish -> { RouteActionType.Finish -> {
projectedRoute = projectedRoute.copy( projectedRoute = projectedRoute.copy(
@@ -46,8 +52,9 @@ fun projectDriverRoute(route: DriverRouteDto, actions: List<RouteActionEntity>):
unloadingWeight = action.weight, unloadingWeight = action.weight,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = action.occurredAt, completedAt = action.occurredAt,
photos = projectedRoute.safePhotos(),
) )
unloadingWeightPending = true unloadingWeightPending = action.status != RouteActionStatus.Confirmed
} }
} }
} }
@@ -67,3 +74,5 @@ fun projectDriverRoutes(routes: List<DriverRouteDto>, actions: List<RouteActionE
fun routeActionNeedsAttention(status: String): Boolean = fun routeActionNeedsAttention(status: String): Boolean =
status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent status == RouteActionStatus.FailedConflict || status == RouteActionStatus.FailedPermanent
internal fun DriverRouteDto.safePhotos(): List<RoutePhotoDto> = photos.orEmpty()
@@ -114,8 +114,10 @@ fun routeSyncCallout(actions: List<RouteActionEntity>): RouteSyncCalloutUi? {
} else { } else {
when (unresolved.status) { when (unresolved.status) {
RouteActionStatus.WaitingForPhotos -> "Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu." RouteActionStatus.WaitingForPhotos -> "Czekam na wysłanie zdjęć etapu, potem wyślę zmianę statusu."
RouteActionStatus.Pending,
RouteActionStatus.Syncing -> "Trwa wysyłanie zmiany statusu kursu."
RouteActionStatus.FailedRetryable -> "Nie udało się wysłać. Aplikacja spróbuje ponownie." RouteActionStatus.FailedRetryable -> "Nie udało się wysłać. Aplikacja spróbuje ponownie."
else -> "Zmiana zapisana w telefonie. Wyślemypo odzyskaniu internetu." else -> "Zmiana zapisana w telefonie. Aplikacja wyśle ją automatycznie."
} }
}, },
isConflict = isConflict, isConflict = isConflict,
@@ -151,9 +153,9 @@ fun routeFlowSteps(route: DriverRouteDto, actions: List<RouteActionEntity>): Lis
) )
} }
fun routePhotosForStage(photos: List<RoutePhotoDto>, stage: String): List<RoutePhotoDto> { fun routePhotosForStage(photos: List<RoutePhotoDto>?, stage: String): List<RoutePhotoDto> {
val normalized = normalizedRoutePhotoStage(stage) val normalized = normalizedRoutePhotoStage(stage)
return photos.filter { normalizedRoutePhotoStage(it.stage) == normalized } return photos.orEmpty().filter { normalizedRoutePhotoStage(it.stage) == normalized }
} }
fun normalizedRoutePhotoStage(stage: String?): String = fun normalizedRoutePhotoStage(stage: String?): String =
@@ -617,6 +617,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
status = "W TRAKCIE", status = "W TRAKCIE",
loadingWeight = weight, loadingWeight = weight,
trackingStatus = "active", trackingStatus = "active",
photos = route.safePhotos(),
) )
} }
"unloading" -> { "unloading" -> {
@@ -626,6 +627,7 @@ class DriverViewModel(application: Application) : AndroidViewModel(application)
unloadingWeight = weight, unloadingWeight = weight,
trackingStatus = "finished", trackingStatus = "finished",
completedAt = Instant.now().toString(), completedAt = Instant.now().toString(),
photos = route.safePhotos(),
) )
} }
else -> route else -> route
@@ -0,0 +1,31 @@
package pl.firmatpp.kierowca.data.upload
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Test
class RouteActionPhotoDependenciesTest {
@Test
fun detectsRouteActionWaitingForConfirmedPhotoClientRequest() {
val action = action("""["photo-a","photo-b"]""")
assertTrue(routeActionReferencesPhotoClientRequest(action, "photo-b"))
assertFalse(routeActionReferencesPhotoClientRequest(action, "photo-c"))
}
@Test
fun malformedPhotoRequestJsonDoesNotMatch() {
assertFalse(routeActionReferencesPhotoClientRequest(action("not-json"), "photo-a"))
}
private fun action(photoClientRequestIdsJson: String): RouteActionEntity =
RouteActionEntity(
clientActionId = "action-1",
routeId = "route-1",
action = RouteActionType.Start,
weight = 12.5,
occurredAt = "2026-06-30T07:40:00Z",
photoClientRequestIdsJson = photoClientRequestIdsJson,
status = RouteActionStatus.WaitingForPhotos,
)
}
@@ -1,11 +1,13 @@
package pl.firmatpp.kierowca.ui package pl.firmatpp.kierowca.ui
import com.google.gson.Gson
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Test import org.junit.Test
import pl.firmatpp.kierowca.data.model.DriverRouteDto import pl.firmatpp.kierowca.data.model.DriverRouteDto
import pl.firmatpp.kierowca.data.model.RoutePhotoDto
import pl.firmatpp.kierowca.data.upload.RouteActionEntity import pl.firmatpp.kierowca.data.upload.RouteActionEntity
import pl.firmatpp.kierowca.data.upload.RouteActionStatus import pl.firmatpp.kierowca.data.upload.RouteActionStatus
import pl.firmatpp.kierowca.data.upload.RouteActionType import pl.firmatpp.kierowca.data.upload.RouteActionType
@@ -57,6 +59,54 @@ class DriverRouteProjectionTest {
assertEquals("finished", projection.route.trackingStatus) assertEquals("finished", projection.route.trackingStatus)
} }
@Test
fun confirmedFinishStillProjectsPlannedRouteAsFinishedWithoutSyncCallout() {
val projection = projectDriverRoute(
route = route(status = "ZAPLANOWANA"),
actions = listOf(
action(RouteActionType.Finish, RouteActionStatus.Confirmed, weight = 11.8, createdAt = 2000),
),
)
assertEquals("ZAKOŃCZONA", projection.route.status)
assertEquals(11.8, projection.route.unloadingWeight)
assertEquals("finished", projection.route.trackingStatus)
assertFalse(projection.unloadingWeightPending)
assertEquals(emptyList<RouteActionEntity>(), projection.visibleActions)
}
@Test
fun routeProjectionNormalizesNullPhotosFromCachedJsonBeforeCopy() {
val route = Gson().fromJson(
"""
{
"id": "1",
"startsAt": "",
"originName": "Baza",
"destinationName": "Instalacja",
"contractorName": "TPP",
"contractName": "Kontrakt",
"contractCode": "TPP-1",
"relationLabel": "Baza -> Instalacja",
"status": "ZAPLANOWANA",
"distanceKm": 1.0,
"notes": null,
"truck": null,
"photos": null
}
""".trimIndent(),
DriverRouteDto::class.java,
)
val projection = projectDriverRoute(
route = route,
actions = listOf(action(RouteActionType.Start, RouteActionStatus.Confirmed, weight = 12.5)),
)
assertEquals("W TRAKCIE", projection.route.status)
assertEquals(emptyList<RoutePhotoDto>(), projection.route.photos)
}
@Test @Test
fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() { fun conflictKeepsLocalRouteValuesAndMarksStageAsAttention() {
val projection = projectDriverRoute( val projection = projectDriverRoute(