Count inventory report loans from Loan rows (PP-4552) - #3763
Conversation
|
Claude finished @dbernstein's task in 1m 51s —— View job Code review
SummaryThis fix looks correct and is ready to merge. Both loan columns now count unexpired DetailsNit:
|
|
Codecov Report✅ All modified and coverable lines are covered by tests. Additional details and impacted files@@ Coverage Diff @@
## main #3763 +/- ##
==========================================
- Coverage 93.74% 93.74% -0.01%
==========================================
Files 510 510
Lines 46412 46417 +5
Branches 6299 6300 +1
==========================================
+ Hits 43510 43514 +4
- Misses 1874 1875 +1
Partials 1028 1028 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
|
Addressed in 501d50c — dropped the untyped I checked Re-verified that the test still fails against the pre-fix query, counting 3 instead of 2. mypy and pre-commit clean. Note for anyone running the suite locally right now: |
72d622a to
256c289
Compare
501d50c to
399fe7e
Compare
256c289 to
500887f
Compare
7625295 to
ef5ce0e
Compare
d556243 to
52df195
Compare
b00a968 to
4b19043
Compare
tdilauro
left a comment
There was a problem hiding this comment.
A couple small comments, but this one is mostly ready to go. 🥇
| exact and uniform across pool types. | ||
|
|
||
| A loan is active until it expires. Expired loans are deleted by | ||
| ``celery.tasks.reaper.loan_reaper``, but only for metered pools and only when that |
There was a problem hiding this comment.
Minor - Both metered and aggregated pool loans get this treatment.
There was a problem hiding this comment.
Correct — fixed in 1105e5e. loan_reaper filters on LicensePool.metered_or_equivalent_type, which is METERED or AGGREGATED, so "only for metered pools" was wrong. The docstring now names the predicate and says which pools genuinely never get reaped (UNLIMITED and open-access).
| ( | ||
| collection_sharing.c.is_shared_collection, | ||
| LicensePool.licenses_reserved, | ||
| func.coalesce(shared_loans.c.active_loan_count, 0), |
There was a problem hiding this comment.
Noting here that shared_active_loan_count includes only loans visible in the PM, while shared_active_hold_count comes from the distributor in some cases (e.g., OverDrive and Bibliotheca) and might include holds created outside of Palace.
I’m not sure how big a deal it is, but I think it’s worth mentioning and understanding the distinction.
There was a problem hiding this comment.
I agree. I have another ticket open that calls out the need for just this kind of distinction. I will create a separate ticket that mirrors that one for holds.
ae45336 to
028f078
Compare
…#3762) ## Description `library_hold_ratio` divided a `bigint` (from `count()`) by an `integer` column, so PostgreSQL truncated every ratio below 1.0 to zero. The numerator is now cast to `numeric` and the result rounded to two places. A title with 31 holds against 61 owned copies reported `0`; it now reports `0.51`. > **Note:** this PR was originally larger and has been re-split. It is now the hold-ratio fix only; all loan-count changes moved to #3763. The branch name is left over from the original split and no longer describes the contents. ## Motivation and Context PP-4552. Library admins reported inventory activity report figures that disagreed with the Collection Manager. ## How Has This Been Tested? New `test_inventory_activity_report_hold_ratio` covers a fractional ratio (31/61), a whole-number ratio, zero holds, and the `-1` no-owned-copies sentinel. Confirmed to fail against the pre-fix query, reporting `0.0` instead of `0.51`. The existing suite missed this because its only non-zero case was 3 holds / 1 license, where truncation is a no-op. 19 tests pass. `mypy` and pre-commit clean. ## Checklist - [x] I have updated the documentation accordingly. - [x] All new and existing tests passed. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
shared_active_loan_count reported LicensePool.licenses_reserved, which is
the number of copies held in reserve for patrons at the head of the hold
queue, not a loan count. That mapping came from the original raw-SQL report
and was carried over verbatim in the SQLAlchemy rewrite.
Deriving the count from the availability counters instead does not work
either, because those counters cannot express a loan count for every pool
type:
- An AGGREGATED (ODL) pool recomputes licenses_owned from its licenses as
min(checkouts_left, terms_concurrency), and a checkout decrements
checkouts_left. A loan therefore shrinks the owned and available counts
together and cancels itself out, so a license on its last checkout
reports zero loans while a patron holds one.
- An UNLIMITED pool holds all four counters at zero by design, so any
count derived from them is always zero.
Both pool types do record their loans, so count Loan rows instead. That is
exact for every pool type and comes from the same source as
library_active_loan_count, which guarantees the library count can never
exceed the shared count.
Fold both loan counts into one lateral helper so they share a definition of
"active", and exclude expired loans from both. Expired loans are deleted by
celery.tasks.reaper.loan_reaper, but only for metered pools and only when
that task next runs, so the count otherwise drifts above what the Collection
Manager shows -- and never settles for unlimited and open-access pools.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two test-file cleanups from review:
- A comment still described shared_active_loan_count as coming from
licenses_reserved, the mapping this PR removes. The assertion still
holds, but now because no patron has a loan on that book.
- test_inventory_activity_report_hold_ratio duplicated the CSV setup that
_activity_report_rows already does. Use the helper, and move it above
its first consumer.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Review feedback: loan_reaper filters on LicensePool.metered_or_equivalent_type, which covers AGGREGATED as well as METERED pools, so "only for metered pools" was wrong. Name the predicate and say which pools are genuinely never reaped. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
4b19043 to
1105e5e
Compare
Description
shared_active_loan_countreportedLicensePool.licenses_reserved— the copies held in reserve for patrons at the head of the hold queue, not a loan count. The mapping came from the original raw-SQL report (#1761) and was carried over verbatim into the SQLAlchemy rewrite.Both loan counts now come from
Loanrows, via a single lateral helper that takes a library filter or not, so the two columns share one definition of "active".The change also excludes expired loans, which
library_active_loan_countwas counting.Motivation and Context
PP-4552.
Why not derive the count from the availability counters. The obvious fix is
licenses_owned - licenses_available - licenses_reserved. That was this PR's first approach, and review (#3762) correctly rejected it — the counters cannot express a loan count for every pool type:AGGREGATED(ODL) pools recomputelicenses_ownedfrom their licenses asmin(checkouts_left, terms_concurrency), andLicense.checkout()decrementscheckouts_left. A loan therefore shrinks the owned and available counts together and cancels itself out. Verified against a live pool: a license with concurrency 1 and 1 checkout left goes to owned 0 / available 0 / reserved 0 the moment it is borrowed, reporting 0 while a patron holds the loan. With concurrency 5 and 3 checkouts left, 2 copies out reports 0 instead of 2.UNLIMITEDpools are imported with all four counters pinned to zero (data_layer/circulation.py), so any derived count is always zero. Verified: a pool with 3 active loans reported 0.Both produced a self-contradicting row, with the library's own loans exceeding the supposedly-broader shared count:
Counting
Loanrows is exact for all three pool types and comes from the same source aslibrary_active_loan_count, solibrary <= sharedholds by construction. It also matches the column's own scope:is_shared_collectionmeans "more than one library on this CM's integration", and theloanstable covers exactly those libraries.Expired loans.
_library_loans_lateralhad no filter onLoan.end, unlike the holds lateral beside it.loan_reaperdeletes expired loans, but only for metered pools and only when it next runs, so the count drifted above the Collection Manager between runs — and forUNLIMITEDand open-access pools was never corrected at all.How Has This Been Tested?
Three new tests:
test_inventory_activity_report_loan_counts— library and shared scoping on one shared collection, with expired loans in both scopes that must not be counted.test_inventory_activity_report_loan_counts_by_pool_type— parametrized overAGGREGATED,UNLIMITEDandMETERED, building realLicenserows for the ODL case so the counter-cancellation path is actually exercised.test_inventory_activity_report_shared_loan_count_unshared_collection— the-1sentinel for unshared collections.All three were confirmed to fail against the original
licenses_reservedquery. The pool-type test was additionally run against the rejected subtraction formula, whereodlandunlimitedfail andmeteredpasses — reproducing the review findings exactly.24 tests pass.
mypyand pre-commit clean.Checklist
🤖 Generated with Claude Code