From 7673b0d85f6aa147824201b4fbc11d44314d14b2 Mon Sep 17 00:00:00 2001 From: fabiodalez-dev Date: Wed, 4 Nov 2026 09:46:05 +0100 Subject: [PATCH] Align reader and staff loans with server calendar decisions --- .../staffe2e/CalendarLoanAlignmentFlowTest.kt | 49 +++++++++ .../java/com/pinakes/app/data/model/Models.kt | 7 +- .../app/data/model/StaffCirculationModels.kt | 7 +- .../pinakes/app/ui/common/StatusMapping.kt | 22 ++++- .../app/ui/screens/library/LibraryScreen.kt | 4 +- .../staff/circulation/CirculationUi.kt | 21 ++-- .../circulation/StaffLoanDetailScreen.kt | 2 +- .../pinakes/app/CalendarLoanAlignmentTest.kt | 99 +++++++++++++++++++ 8 files changed, 193 insertions(+), 18 deletions(-) create mode 100644 app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarLoanAlignmentFlowTest.kt create mode 100644 app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt diff --git a/app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarLoanAlignmentFlowTest.kt b/app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarLoanAlignmentFlowTest.kt new file mode 100644 index 00000000..2197b222 --- /dev/null +++ b/app/src/androidTest/java/com/pinakes/app/staffe2e/CalendarLoanAlignmentFlowTest.kt @@ -0,0 +1,49 @@ +package com.pinakes.app.staffe2e + +import com.pinakes.app.R +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** Native desk follows the branch-calendar facts returned by the server. */ +class CalendarLoanAlignmentFlowTest : AppFlowTest() { + private fun loan(status: String, overdue: Boolean, actions: String) = """{ + "id":7,"status":"$status","is_overdue":$overdue, + "book":{"id":11,"title":"Calendar-aligned loan"}, + "copy":{"id":1,"inventory_number":"INV-CALENDAR"}, + "user":{"id":3,"name":"Calendar reader"}, + "due_at":"2030-12-25","effective_due_at":"2030-12-27", + "renewals":0,"max_renewals":3,"days_overdue":${if (overdue) 1 else 0}, + "actions":$actions + }""" + + private fun desk(payload: String) { + launch { + json("GET staff/circulation/summary", "circulation_summary") + body("GET staff/loans", 200, """{"data":[$payload],"meta":{"count":1,"has_more":false},"error":null}""") + body("GET staff/loans/7", 200, """{"data":$payload,"meta":{},"error":null}""") + body("POST staff/loans/7/renew", 200, """{"data":$payload,"meta":{},"error":null}""") + } + openStaffHub() + click(text(R.string.staff_circulation_desk)) + clickTag("counter-active") + click("Calendar-aligned loan") + waitText("INV-CALENDAR") + } + + @Test fun firstOpenDayOffersRenewalAndSendsItToTheServer() { + desk(loan("in_ritardo", false, """["return","renew","edit","receipt"]""")) + assertFalse(exists(text(R.string.staff_loan_action_recall))) + clickTag("action-renew") + clickInDialog(text(R.string.staff_loan_action_renew)) + waitRequest("POST", "staff/loans/7/renew") + assertTrue(api.recorded("POST", "staff/loans/7/renew").size == 1) + } + + @Test fun genuinelyLateLoanOffersRecallAndNoRenewal() { + desk(loan("in_corso", true, """["return","recall","edit","receipt"]""")) + waitText(text(R.string.staff_loan_action_recall)) + assertFalse(exists(text(R.string.staff_loan_action_renew))) + assertTrue(api.recorded("POST", "staff/loans/7/renew").isEmpty()) + } +} diff --git a/app/src/main/java/com/pinakes/app/data/model/Models.kt b/app/src/main/java/com/pinakes/app/data/model/Models.kt index 0bbd2018..78643c89 100644 --- a/app/src/main/java/com/pinakes/app/data/model/Models.kt +++ b/app/src/main/java/com/pinakes/app/data/model/Models.kt @@ -527,13 +527,18 @@ data class LoanItem( @SerialName("requested_at") val requestedAt: String? = null, @SerialName("loaned_at") val loanedAt: String? = null, @SerialName("due_at") val dueAt: String? = null, + @SerialName("effective_due_at") val effectiveDueAt: String? = null, + // Nullable for older servers: only the server knows branch closures and library time. + @SerialName("is_overdue") val isOverdue: Boolean? = null, // Computed in the library timezone; do not re-derive from device LocalDate. @SerialName("due_attention") val dueAttention: Boolean = false, @SerialName("returned_at") val returnedAt: String? = null, val renewals: Int? = null, // Mobile API 1.4.4 presentation hint; DELETE /loans/{id} revalidates it. val cancellable: Boolean = false, -) +) { + val displayDueAt: String? get() = effectiveDueAt ?: dueAt +} @Serializable data class ReservationItem( diff --git a/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt b/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt index 134c9af8..6cf86bac 100644 --- a/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt +++ b/app/src/main/java/com/pinakes/app/data/model/StaffCirculationModels.kt @@ -48,6 +48,8 @@ data class StaffLoan( @SerialName("requested_at") val requestedAt: String? = null, @SerialName("loaned_at") val loanedAt: String? = null, @SerialName("due_at") val dueAt: String? = null, + @SerialName("effective_due_at") val effectiveDueAt: String? = null, + @SerialName("is_overdue") val isOverdue: Boolean? = null, @SerialName("pickup_deadline") val pickupDeadline: String? = null, @SerialName("returned_at") val returnedAt: String? = null, @SerialName("returned_late") val returnedLate: Boolean = false, @@ -64,7 +66,10 @@ data class StaffLoan( val actions: List = emptyList(), /** Mobile API 1.10.0: the late fine (detail and return answers); null while fines are off. */ @SerialName("late_fine") val lateFine: StaffLoanLateFine? = null, -) +) { + /** Display the usable return day; the edit form keeps the stored dueAt. */ + val displayDueAt: String? get() = effectiveDueAt ?: dueAt +} @Serializable data class StaffReservation( diff --git a/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt b/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt index e7d48f8b..8e79dc26 100644 --- a/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt +++ b/app/src/main/java/com/pinakes/app/ui/common/StatusMapping.kt @@ -3,6 +3,7 @@ package com.pinakes.app.ui.common import androidx.annotation.StringRes import com.pinakes.app.R import com.pinakes.app.data.model.LoanItem +import com.pinakes.app.data.model.StaffLoan import com.pinakes.app.ui.components.AvailabilityStatus /** @@ -21,6 +22,19 @@ data class StatusLabel(@param:StringRes val resId: Int?, val fallback: String? = */ object StatusMapping { + private fun calendarStatus(status: String, isOverdue: Boolean?): String = + if (isOverdue != null && status in setOf("in_corso", "in_ritardo")) { + if (isOverdue) "in_ritardo" else "in_corso" + } else status + + fun loan(loan: LoanItem): Pair = + this.loan(calendarStatus(loan.status, loan.isOverdue), loan.statusLabel) + + fun loan(loan: StaffLoan): Pair = + this.loan(calendarStatus(loan.status, loan.isOverdue), loan.statusLabel) + + fun loanGroup(loan: LoanItem): LoanGroup = loanGroup(calendarStatus(loan.status, loan.isOverdue)) + fun loan(stato: String, serverLabel: String? = null): Pair = when (stato) { // Active, on time — available-green (this is the good state). "in_corso" -> AvailabilityStatus.Available to StatusLabel(R.string.loan_status_on_loan) @@ -87,7 +101,7 @@ object StatusMapping { /** Server computes this in the library timezone; stale `in_corso` rows may need attention too. */ fun loanNeedsAttention(loan: LoanItem): Boolean = - loan.dueAttention || loanGroup(loan.status) == LoanGroup.Overdue + loan.dueAttention || loanGroup(loan) == LoanGroup.Overdue enum class LoanDateKind { Overdue, Returned, Requested, Due, Borrowed } @@ -99,13 +113,13 @@ object StatusMapping { * requested_at must win over the requested loan interval's due date. */ fun loanDate(loan: LoanItem): LoanDate? { - val overdue = loanGroup(loan.status) == LoanGroup.Overdue - if (overdue) return LoanDate(LoanDateKind.Overdue, loan.dueAt) + val overdue = loanGroup(loan) == LoanGroup.Overdue + if (overdue) return LoanDate(LoanDateKind.Overdue, loan.displayDueAt) if (loan.status in setOf("pendente", "in_attesa", "annullato", "scaduto")) { return loan.requestedAt?.let { LoanDate(LoanDateKind.Requested, it) } } if (loan.returnedAt != null) return LoanDate(LoanDateKind.Returned, loan.returnedAt) - if (loan.dueAt != null) return LoanDate(LoanDateKind.Due, loan.dueAt) + if (loan.displayDueAt != null) return LoanDate(LoanDateKind.Due, loan.displayDueAt) if (loan.loanedAt != null) return LoanDate(LoanDateKind.Borrowed, loan.loanedAt) return null } diff --git a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt index a6c2a2df..1ae40f68 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/library/LibraryScreen.kt @@ -178,7 +178,7 @@ private fun ActiveLoanList( } // Group by urgency bucket, preserving the bucket order (Overdue → … → Pending). val grouped = loans - .groupBy { StatusMapping.loanGroup(it.status) } + .groupBy { StatusMapping.loanGroup(it) } .toSortedMap(compareBy { it.order }) LazyColumn( @@ -250,7 +250,7 @@ private fun androidx.compose.foundation.lazy.LazyItemScope.LoanRow( onBookClick: (Int) -> Unit, onCancel: ((Int) -> Unit)? = null, ) { - val (status, statusLabel) = StatusMapping.loan(loan.status, loan.statusLabel) + val (status, statusLabel) = StatusMapping.loan(loan) val label = statusLabel.resId?.let { stringResource(it) } ?: statusLabel.fallback val attention = StatusMapping.loanNeedsAttention(loan) val dateLine = when (val date = StatusMapping.loanDate(loan)) { diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt index 0dc876e8..fe9a31c2 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/CirculationUi.kt @@ -52,7 +52,7 @@ import com.pinakes.app.ui.theme.Spacing /** Chip status + localized label for a loan state (`status_label` for states the app does not know). */ @Composable internal fun loanChip(loan: StaffLoan): Pair { - val (status, label) = StatusMapping.loan(loan.status, loan.statusLabel) + val (status, label) = StatusMapping.loan(loan) return status to (label.resId?.let { stringResource(it) } ?: label.fallback) } @@ -64,14 +64,17 @@ internal fun reservationChip(reservation: StaffReservation): Pair 0 -> stringResource(R.string.staff_loan_days_overdue, loan.daysOverdue) - loan.status == "da_ritirare" && loan.pickupDeadline != null -> stringResource(R.string.staff_loan_pickup_by, DateFormat.date(loan.pickupDeadline)) - loan.returnedAt != null -> stringResource(R.string.library_returned_on, DateFormat.date(loan.returnedAt)) - loan.status == "pendente" && loan.requestedAt != null -> stringResource(R.string.library_requested_on, DateFormat.date(loan.requestedAt)) - loan.status == "prenotato" && loan.loanedAt != null -> stringResource(R.string.staff_loan_starts_on, DateFormat.date(loan.loanedAt)) - loan.dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(loan.dueAt)) - else -> null +internal fun loanDateLine(loan: StaffLoan): String? { + val dueAt = loan.displayDueAt + return when { + (loan.isOverdue ?: (loan.status == "in_ritardo")) && loan.daysOverdue > 0 -> stringResource(R.string.staff_loan_days_overdue, loan.daysOverdue) + loan.status == "da_ritirare" && loan.pickupDeadline != null -> stringResource(R.string.staff_loan_pickup_by, DateFormat.date(loan.pickupDeadline)) + loan.returnedAt != null -> stringResource(R.string.library_returned_on, DateFormat.date(loan.returnedAt)) + loan.status == "pendente" && loan.requestedAt != null -> stringResource(R.string.library_requested_on, DateFormat.date(loan.requestedAt)) + loan.status == "prenotato" && loan.loanedAt != null -> stringResource(R.string.staff_loan_starts_on, DateFormat.date(loan.loanedAt)) + dueAt != null -> stringResource(R.string.library_due_label, DateFormat.date(dueAt)) + else -> null + } } /** One loan at the desk: cover, title, borrower and the date that matters. */ diff --git a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt index 4386c0d7..6e0a702e 100644 --- a/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt +++ b/app/src/main/java/com/pinakes/app/ui/screens/staff/circulation/StaffLoanDetailScreen.kt @@ -369,7 +369,7 @@ fun StaffLoanDetailScreen(onNavigateUp: () -> Unit, onOpenUser: (Int) -> Unit, o InfoLine(stringResource(R.string.staff_copy_inventory), loan.copy?.inventoryNumber) InfoLine(stringResource(R.string.staff_loan_requested), loan.requestedAt?.let(DateFormat::date)) InfoLine(stringResource(R.string.staff_loan_loaned), loan.loanedAt?.let(DateFormat::date)) - InfoLine(stringResource(R.string.staff_loan_due), loan.dueAt?.let(DateFormat::date)) + InfoLine(stringResource(R.string.staff_loan_due), loan.displayDueAt?.let(DateFormat::date)) InfoLine(stringResource(R.string.staff_loan_pickup_deadline), loan.pickupDeadline?.let(DateFormat::date)) InfoLine(stringResource(R.string.staff_loan_returned_at), loan.returnedAt?.let(DateFormat::date)) InfoLine(stringResource(R.string.staff_loan_renewals), stringResource(R.string.staff_loan_renewals_value, loan.renewals, loan.maxRenewals)) diff --git a/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt b/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt new file mode 100644 index 00000000..c1d14183 --- /dev/null +++ b/app/src/test/java/com/pinakes/app/CalendarLoanAlignmentTest.kt @@ -0,0 +1,99 @@ +package com.pinakes.app + +import com.pinakes.app.data.model.LoanItem +import com.pinakes.app.data.model.StaffLoan +import com.pinakes.app.data.staff.LoanAction +import com.pinakes.app.data.staff.LoanActions +import com.pinakes.app.ui.common.StatusMapping +import com.pinakes.app.ui.components.AvailabilityStatus +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The server, not the phone clock, knows the copy branch calendar. */ +class CalendarLoanAlignmentTest { + private val json = Json { ignoreUnknownKeys = true } + + @Test fun firstOpenReturnDayUsesServerDateAndClearsAStaleLateStatus() { + val loan = json.decodeFromString("""{ + "id":7,"status":"in_ritardo","due_at":"2030-12-25", + "effective_due_at":"2030-12-27","is_overdue":false,"due_attention":false + }""") + assertEquals("2030-12-25", loan.dueAt) + assertEquals("2030-12-27", loan.displayDueAt) + assertEquals(StatusMapping.LoanGroup.OnLoan, StatusMapping.loanGroup(loan)) + assertEquals(AvailabilityStatus.Available, StatusMapping.loan(loan).first) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Due, "2030-12-27"), StatusMapping.loanDate(loan)) + assertFalse(StatusMapping.loanNeedsAttention(loan)) + } + + @Test fun overdueDecisionWinsOverAnUnreconciledActiveStatus() { + val loan = LoanItem(status = "in_corso", dueAt = "2030-12-25", effectiveDueAt = "2030-12-27", isOverdue = true) + assertEquals(StatusMapping.LoanGroup.Overdue, StatusMapping.loanGroup(loan)) + assertEquals(AvailabilityStatus.Overdue, StatusMapping.loan(loan).first) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Overdue, "2030-12-27"), StatusMapping.loanDate(loan)) + assertTrue(StatusMapping.loanNeedsAttention(loan)) + } + + @Test fun dueTodayAttentionDoesNotTurnAnOnTimeLoanIntoAnOverdueOne() { + val loan = LoanItem(status = "in_corso", isOverdue = false, dueAttention = true) + assertTrue(StatusMapping.loanNeedsAttention(loan)) + assertEquals(StatusMapping.LoanGroup.OnLoan, StatusMapping.loanGroup(loan)) + assertEquals(R.string.loan_status_on_loan, StatusMapping.loan(loan).second.resId) + } + + @Test fun olderReaderPayloadKeepsItsPreviousStatusAndDate() { + val loan = json.decodeFromString("""{"status":"in_ritardo","due_at":"2030-12-25"}""") + assertNull(loan.isOverdue) + assertNull(loan.effectiveDueAt) + assertEquals(StatusMapping.LoanGroup.Overdue, StatusMapping.loanGroup(loan)) + assertEquals("2030-12-25", loan.displayDueAt) + } + + @Test fun historyAndUncollectedRequestsKeepTheirOwnTimeline() { + val returned = LoanItem(status = "restituito", dueAt = "2030-12-25", effectiveDueAt = "2030-12-27", isOverdue = false, returnedAt = "2030-12-24") + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Returned, "2030-12-24"), StatusMapping.loanDate(returned)) + val pending = LoanItem(status = "pendente", requestedAt = "2030-12-20", effectiveDueAt = "2030-12-27", isOverdue = false) + assertEquals(StatusMapping.LoanDate(StatusMapping.LoanDateKind.Requested, "2030-12-20"), StatusMapping.loanDate(pending)) + } + + @Test fun staffCanRenewOnTheFirstOpenDayWithoutRewritingTheStoredEditDate() { + val loan = json.decodeFromString("""{ + "id":7,"status":"in_ritardo","due_at":"2030-12-25", + "effective_due_at":"2030-12-27","is_overdue":false,"days_overdue":0, + "actions":["return","renew","edit","receipt"] + }""") + assertEquals("2030-12-25", loan.dueAt) + assertEquals("2030-12-27", loan.displayDueAt) + assertEquals(AvailabilityStatus.Available, StatusMapping.loan(loan).first) + assertTrue(LoanActions.of(loan).contains(LoanAction.RENEW)) + assertFalse(LoanActions.of(loan).contains(LoanAction.RECALL)) + } + + @Test fun staffOverdueStatusUsesTheServerCalendarAndItsActionList() { + val loan = json.decodeFromString("""{ + "status":"in_corso","due_at":"2030-12-25","effective_due_at":"2030-12-27", + "is_overdue":true,"days_overdue":1,"actions":["return","recall"] + }""") + assertEquals(AvailabilityStatus.Overdue, StatusMapping.loan(loan).first) + assertEquals(1, loan.daysOverdue) + assertTrue(LoanActions.of(loan).contains(LoanAction.RECALL)) + assertFalse(LoanActions.of(loan).contains(LoanAction.RENEW)) + } + + @Test fun olderStaffPayloadKeepsItsDateStatusAndActions() { + val loan = json.decodeFromString("""{"status":"in_ritardo","due_at":"2030-12-25","actions":["recall"]}""") + assertNull(loan.isOverdue) + assertEquals("2030-12-25", loan.displayDueAt) + assertEquals(AvailabilityStatus.Overdue, StatusMapping.loan(loan).first) + assertEquals(listOf(LoanAction.RECALL), LoanActions.of(loan)) + } + + @Test fun futureUnknownStatusesKeepTheirServerLabel() { + val loan = LoanItem(status = "future_state", statusLabel = "Future loan", isOverdue = false) + assertEquals("Future loan", StatusMapping.loan(loan).second.fallback) + } +}