From 70337d593e2a5ac14a825c1395e6d066065b4e49 Mon Sep 17 00:00:00 2001 From: Martin Renvoize <martin.renvoize@ptfs-europe.com> Date: Thu, 23 Sep 2021 11:37:52 +0100 Subject: [PATCH] Bug 28854: Update circulation functionality for bundles This patch updates the circulation system to account for bundle checkins. We add a content verification step to ensure bundle content is all present at checkin and we use this comparison to mark missing items as lost. Test plan 0) Apply patches up to this point 1) Checkin an item that belongs to a bundle * An alert should be triggered noting that the item belongs to a bundle * The option to remove the item from the bundle should be clear * Click remove should result in the alert dissapearing and the item having been removed from the bundle. 2) Checkin an item bundle * A modal confirmation dialog should appear requesting each item barcode be scanned * As items are scanned they should be highlighted in yellow in the bundle content table * Upon submission; * The user will be alerted to any unexpected items that were scanned and told to put them to one side. * The user will be alerted that any missing items in the validation will have been marked as lost. * The bundle item will be marked as checked in. https://bugs.koha-community.org/show_bug.cgi?id=24454 --- C4/Circulation.pm | 6 + Koha/Item.pm | 16 ++ circ/returns.pl | 64 +++++++- .../prog/en/includes/item-status.inc | 84 +++++++++++ .../en/includes/modals/bundle_contents.inc | 35 +++++ .../admin/preferences/circulation.pref | 4 + .../prog/en/modules/circ/returns.tt | 138 ++++++++++++++++++ 7 files changed, 343 insertions(+), 4 deletions(-) create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/item-status.inc create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc diff --git a/C4/Circulation.pm b/C4/Circulation.pm index 5da1d802587..99f3c817cd9 100644 --- a/C4/Circulation.pm +++ b/C4/Circulation.pm @@ -2308,6 +2308,12 @@ sub AddReturn { } } + # Check for bundle status + if ( $item->in_bundle ) { + my $host = $item->bundle_host; + $messages->{InBundle} = $host; + } + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); $indexer->index_records( $item->biblionumber, "specialUpdate", "biblioserver" ); diff --git a/Koha/Item.pm b/Koha/Item.pm index 2a69ab13d00..e7ca0397220 100644 --- a/Koha/Item.pm +++ b/Koha/Item.pm @@ -395,6 +395,22 @@ sub checkout { return Koha::Checkout->_new_from_dbic( $checkout_rs ); } +=head3 last_checkout + + my $old_checkout = $item->last_checkout; + +Return the last_checkout for this item + +=cut + +sub last_checkout { + my ( $self ) = @_; + my $checkout_rs = $self->_result->old_issues->search( {}, + { order_by => { '-desc' => 'returndate' }, rows => 1 } )->single; + return unless $checkout_rs; + return Koha::Old::Checkout->_new_from_dbic( $checkout_rs ); +} + =head3 holds my $holds = $item->holds(); diff --git a/circ/returns.pl b/circ/returns.pl index 914d5de35c2..1e45c50ae59 100755 --- a/circ/returns.pl +++ b/circ/returns.pl @@ -38,7 +38,6 @@ use C4::Auth qw( get_template_and_user get_session haspermission ); use C4::Output qw( output_html_with_http_headers ); use C4::Circulation qw( barcodedecode GetBranchItemRule AddReturn updateWrongTransfer LostItem ); use C4::Reserves qw( ModReserve ModReserveAffect GetOtherReserves ); -use C4::Circulation qw( barcodedecode GetBranchItemRule AddReturn updateWrongTransfer LostItem ); use C4::Context; use C4::Items qw( ModItemTransfer ); use C4::Members::Messaging; @@ -301,10 +300,18 @@ if ($barcode) { $template->param( 'multiple_confirmed' => 1 ) if $query->param('multiple_confirm'); + # Block return if bundle and confirm has not been received + my $bundle_confirm = + $item + && $item->is_bundle + && !$query->param('confirm_items_bundle_return'); + $template->param( 'confirm_items_bundle_returned' => 1 ) + if $query->param('confirm_items_bundle_return'); + # do the return ( $returned, $messages, $issue, $borrower ) = AddReturn( $barcode, $userenv_branch, $exemptfine, $return_date ) - unless $needs_confirm; + unless ( $needs_confirm || $bundle_confirm ); if ($returned) { my $time_now = dt_from_string()->truncate( to => 'minute'); @@ -345,7 +352,46 @@ if ($barcode) { ); } } - } elsif ( C4::Context->preference('ShowAllCheckins') and !$messages->{'BadBarcode'} and !$needs_confirm ) { + + # Mark missing bundle items as lost and report unexpected items + if ( $item->is_bundle ) { + my $BundleLostValue = C4::Context->preference('BundleLostValue'); + my $checkout = $item->last_checkout; + my $barcodes = $query->param('verify-items-bundle-contents-barcodes'); + my @barcodes = map { s/^\s+|\s+$//gr } ( split /\n/, $barcodes ); + my $expected_items = { map { $_->barcode => $_ } $item->bundle_items->as_list }; + my $verify_items = Koha::Items->search( { barcode => { 'in' => \@barcodes } } ); + my @unexpected_items; + my @missing_items; + my @bundle_items; + while ( my $verify_item = $verify_items->next ) { + # Fix and lost statuses + $verify_item->itemlost(0); + + # Expected item, remove from lookup table + if ( delete $expected_items->{$verify_item->barcode} ) { + push @bundle_items, $verify_item; + } + # Unexpected item, warn and remove from bundle + else { + $verify_item->remove_from_bundle; + push @unexpected_items, $verify_item; + } + # Store results + $verify_item->store(); + } + for my $missing_item ( keys %{$expected_items} ) { + my $bundle_item = $expected_items->{$missing_item}; + $bundle_item->itemlost($BundleLostValue)->store(); + push @missing_items, $bundle_item; + } + $template->param( + unexpected_items => \@unexpected_items, + missing_items => \@missing_items, + bundle_items => \@bundle_items + ); + } + } elsif ( C4::Context->preference('ShowAllCheckins') and !$messages->{'BadBarcode'} and !$needs_confirm and !$bundle_confirm ) { $input{duedate} = 0; $returneditems{0} = $barcode; $riduedate{0} = 0; @@ -356,6 +402,12 @@ if ($barcode) { if ( $needs_confirm ) { $template->param( needs_confirm => $needs_confirm ); } + + if ( $bundle_confirm ) { + $template->param( + items_bundle_return_confirmation => 1, + ); + } } $template->param( inputloop => \@inputloop ); @@ -553,7 +605,11 @@ foreach my $code ( keys %$messages ) { } elsif ( $code eq 'ReturnClaims' ) { $template->param( ReturnClaims => $messages->{ReturnClaims} ); - } else { + } + elsif ( $code eq 'InBundle' ) { + $template->param( InBundle => $messages->{InBundle} ); + } + else { die "Unknown error code $code"; # note we need all the (empty) elsif's above, or we die. # This forces the issue of staying in sync w/ Circulation.pm } diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/item-status.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/item-status.inc new file mode 100644 index 00000000000..ca6dc1d1ff1 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/item-status.inc @@ -0,0 +1,84 @@ +[% USE AuthorisedValues %] +[% USE Branches %] +[% USE Koha %] +[% USE KohaDates %] +[% PROCESS 'i18n.inc' %] +[% transfer = item.get_transfer() %] +[% IF item.checkout %] + <span class="datedue"> + [% IF item.checkout.onsite_checkout %] + [% t('Currently in local use by') | html %] + [% ELSE %] + [% t('Checked out to') | html %] + [% END %] + [% INCLUDE 'patron-title.inc' patron=item.checkout.patron hide_patron_infos_if_needed=1 %] + : [% tx('due {date_due}', { date_due = item.checkout.date_due }) | html %] + </span> +[% ELSIF transfer %] + [% datesent = BLOCK %][% transfer.datesent | $KohaDates %][% END %] + <span class="intransit">[% tx('In transit from {frombranch} to {tobranch} since {datesent}', { frombranch = Branches.GetName(transfer.frombranch), tobranch = Branches.GetName(transfer.tobranch), datesent = datesent }) %]</span> +[% END %] + +[% IF item.itemlost %] + [% itemlost_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.itemlost', authorised_value = item.itemlost }) %] + [% IF itemlost_description %] + <span class="lost">[% itemlost_description | html %]</span> + [% ELSE %] + <span class="lost">[% t('Unavailable (lost or missing)') | html %]</span> + [% END %] +[% END %] + +[% IF item.withdrawn %] + [% withdrawn_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.withdrawn', authorised_value = item.withdrawn }) %] + [% IF withdrawn_description %] + <span class="wdn">[% withdrawn_description | html %]</span> + [% ELSE %] + <span class="wdn">[% t('Withdrawn') | html %]</span> + [% END %] +[% END %] + +[% IF item.damaged %] + [% damaged_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.damaged', authorised_value = item.damaged }) %] + [% IF damaged_description %] + <span class="dmg">[% damaged_description | html %]</span> + [% ELSE %] + <span class="dmg">[% t('Damaged') | html %]</span> + [% END %] +[% END %] + +[% IF item.notforloan || item.effective_itemtype.notforloan %] + <span> + [% t('Not for loan') | html %] + [% notforloan_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.notforloan', authorised_value = item.notforloan }) %] + [% IF notforloan_description %] + ([% notforloan_description | html %]) + [% END %] + </span> +[% END %] + +[% hold = item.holds.next %] +[% IF hold %] + <span> + [% IF hold.waitingdate %] + Waiting at [% Branches.GetName(hold.get_column('branchcode')) | html %] since [% hold.waitingdate | $KohaDates %]. + [% ELSE %] + Item-level hold (placed [% hold.reservedate | $KohaDates %]) for delivery at [% Branches.GetName(hold.get_column('branchcode')) | html %]. + [% END %] + [% IF Koha.Preference('canreservefromotherbranches') %] + [% t('Hold for:') | html %] + [% INCLUDE 'patron-title.inc' patron=hold.borrower hide_patron_infos_if_needed=1 %] + [% END %] + </span> +[% END %] +[% UNLESS item.notforloan || item.effective_itemtype.notforloan || item.onloan || item.itemlost || item.withdrawn || item.damaged || transfer || hold %] + <span>[% t('Available') | html %]</span> +[% END %] + +[% IF ( item.restricted ) %] + [% restricted_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.restricted', authorised_value = item.restricted }) %] + [% IF restricted_description %] + <span class="restricted">([% restricted_description | html %])</span> + [% ELSE %] + <span class="restricted">[% t('Restricted') | html %]</span> + [% END %] +[% END %] diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc new file mode 100644 index 00000000000..c88822b4e98 --- /dev/null +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc @@ -0,0 +1,35 @@ +<!-- Bundle contents modal --> +<div class="modal printable" id="bundleContentsModal" tabindex="-1" role="dialog" aria-labelledby="bundleContentsLabel"> + <div class="modal-dialog" role="document"> + <div class="modal-content"> + <div class="modal-header"> + <button type="button" class="closebtn" data-dismiss="modal" aria-label="Close"><span aria-hidden="true">×</span></button> + <h4 class="modal-title" id="bundleContentsLabel">Bundle contents</h4> + </div> + <div class="modal-body"> + <table style="width:100%"> + <thead> + <tr> + <th>Barcode</th> + <th>Title</th> + </tr> + </thead> + <tbody> + [% FOREACH bundle_item IN bundle_items %] + <tr> + <td>[% bundle_item.barcode | html %]</td> + <td>[% INCLUDE 'biblio-title.inc' biblio=bundle_item.biblio %]</td> + </tr> + [% END %] + </tbody> + <tfoot> + </tfoot> + </table> + </div> <!-- /.modal-body --> + <div class="modal-footer"> + <button type="button" class="btn btn-default" data-dismiss="modal">Close</button> + <button type="button" class="printModal btn btn-primary"><i class="fa fa-print"></i> Print</button> + </div> <!-- /.modal-footer --> + </div> <!-- /.modal-content --> + </div> <!-- /.modal-dialog --> +</div> <!-- /#bundleContentsModal --> diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref index fc07ec1ed65..21b4349d0f0 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref @@ -12,6 +12,10 @@ Circulation: 1: "Require" 0: "Don't require" - staff to confirm that all parts of an item are present at checkin/checkout. + - + - Use the LOST authorised value + - pref: BundleLostValue + - to represent 'missing from bundle' at return. - - pref: AutoSwitchPatron choices: diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt index 27e5c305da8..5be9b379603 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt +++ b/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt @@ -6,6 +6,7 @@ [% USE ItemTypes %] [% USE AuthorisedValues %] [% USE TablesSettings %] +[% PROCESS 'i18n.inc' %] [% PROCESS 'member-display-address-style.inc' %] [% SET footerjs = 1 %] [% BLOCK display_bormessagepref %] @@ -217,6 +218,36 @@ </div> [% END %] + <!-- Bundle has items missing --> + [% IF missing_items %] + <div id="bundle_missing_items" class="dialog alert"> + <h3>Bundle had missing items</h3> + <p>Print new contents list + <a class="btn btn-default btn-xs" role="button" data-toggle="modal" href="#bundleContentsModal"><i class="fa fa-glass" aria-hidden="true"></i> Show</a></p> + </div> + [% END %] + + <!-- Bundle contained unexpected items --> + [% IF unexpected_items %] + <div id="bundle_unexpected_items" class="dialog alert"> + <h3>Bundle had unexpected items</h3> + <p>Please place the following items to one side</p> + <ul> + [% FOREACH unexpected_item IN unexpected_items %] + <li>[% INCLUDE 'biblio-title.inc' biblio=unexpected_item.biblio %] - [% unexpected_item.barcode | html %]</li> + [% END %] + </ul> + </div> + [% END %] + + <!-- Item checked in outside of bundle --> + [% IF InBundle %] + <div id="" class="dialog alert audio-alert-warning"> + <h3>Item belongs in bundle</h3> + <p>This item belongs to a bundle: [% INCLUDE 'biblio-title.inc' biblio=InBundle.biblio %] - [% InBundle.barcode | html %]</p> + <p><button class="btn btn-default btn-xs bundle_remove" role="button" data-itemnumber="[% itemnumber | uri %]" data-hostnumber="[% InBundle.itemnumber | uri %]"><i class="fa fa-minus"></i> Remove from bundle</button></p> + </div> + [% END %] [% IF ( errmsgloop ) %] <div class="dialog alert audio-alert-warning"> @@ -381,6 +412,67 @@ </div> [% END %] + [% IF items_bundle_return_confirmation %] + <div id="bundle-needsconfirmation-modal" class="modal fade audio-alert-action block"> + <div class="modal-dialog modal-wide"> + <div class="modal-content"> + <form method="post"> + <div class="modal-header"> + <h3>Please confirm bundle contents</h3> + </div> + <div class="modal-body"> + + <table class="table table-condensed table-bordered" id="items-bundle-contents-table"> + <thead> + <tr> + <th>[% t('Title') | html %]</th> + <th>[% t('Author') | html %]</th> + <th>[% t('Collection code') | html %]</th> + <th>[% t('Item type') | html %]</th> + <th>[% t('Callnumber') | html %]</th> + <th>[% t('Barcode') | html %]</th> + <th>[% t('Status') | html %]</th> + </tr> + </thead> + <tbody> + [% FOREACH bundle_item IN item.bundle_items %] + <tr data-barcode="[% bundle_item.barcode | html %]"> + <td>[% INCLUDE 'biblio-title.inc' biblio=bundle_item.biblio link = 1 %]</td> + <td>[% bundle_item.biblio.author | html %]</td> + <td>[% AuthorisedValues.GetByCode('CCODE', bundle_item.ccode) | html %]</td> + <td>[% ItemTypes.GetDescription(bundle_item.itype) | html %]</td> + <td>[% bundle_item.itemcallnumber | html %]</td> + <td>[% bundle_item.barcode | html %]</td> + <td>[% INCLUDE 'item-status.inc' item=bundle_item %]</td> + </tr> + [% END %] + </tbody> + </table> + + <div class="form-group"> + <label for="verify-items-bundle-contents-barcodes">Barcodes</label> + <textarea autocomplete="off" id="verify-items-bundle-contents-barcodes" name="verify-items-bundle-contents-barcodes" class="form-control"></textarea> + <div class="help-block">[% t('Scan all barcodes of items found in the items bundle. If any items are missing, they will be marked as lost') | html %]</div> + </div> + + </div> + <div class="modal-footer"> + <input type="hidden" name="barcode" value="[% item.barcode | html %]"> + <input type="hidden" name="confirm_items_bundle_return" value="1"> + [% FOREACH inputloo IN inputloop %] + <input type="hidden" name="ri-[% inputloo.counter | html %]" value="[% inputloo.barcode | html %]" /> + <input type="hidden" name="dd-[% inputloo.counter | html %]" value="[% inputloo.duedate | html %]" /> + <input type="hidden" name="bn-[% inputloo.counter | html %]" value="[% inputloo.borrowernumber | html %]" /> + [% END %] + <button type="submit" class="btn btn-default"><i class="fa fa-check"></i> [% t('Confirm checkin and mark missing items as lost') | html %]</button> + <button type="button" data-dismiss="modal" class="btn btn-default"><i class="fa fa-close"></i> [% t('Cancel') | html %]</button> + </div> + </form> + </div> + </div> + </div> + [% END %] + [% IF wrongbranch %] <div id="wrong-branch-modal" class="modal fade audio-alert-action block"> <div class="modal-dialog"> @@ -986,6 +1078,8 @@ </div> <!-- /.col-sm-12 --> </div> <!-- /.row --> + [% INCLUDE 'modals/bundle_contents.inc' %] + [% MACRO jsinclude BLOCK %] [% INCLUDE 'datatables.inc' %] [% INCLUDE 'columns_settings.inc' %] @@ -1188,8 +1282,52 @@ window.open("/cgi-bin/koha/members/printslip.pl?borrowernumber=" + borrowernumber + "&print=checkinslip", "printwindow"); }); + // item bundles + $('#verify-items-bundle-contents-barcodes').on('input', function (ev) { + const barcodes = ev.target.value.split('\n').map(function(s) { return s.trim() }); + $('#items-bundle-contents-table tr').each(function () { + const barcode = this.getAttribute('data-barcode'); + if (barcodes.includes(barcode)) { + this.classList.add('ok'); + } else { + this.classList.remove('ok'); + } + }) + }); + + $('.bundle_remove').on('click', function() { + var component_itemnumber = $(this).data('itemnumber'); + var host_itemnumber = $(this).data('hostnumber'); + var alert = $(this).closest('div'); + var unlink_item_url = "/api/v1/items/" + host_itemnumber + "/bundled_items/item/" + component_itemnumber; + $.ajax({ + type: "DELETE", + url: unlink_item_url, + success: function(){ + alert.remove(); + } + }); + }); + + // print modals + $('.modal.printable').on('shown.bs.modal', function() { + $('.modal-dialog', this).addClass('focused'); + $('body').addClass('modalprinter'); + + if ($(this).hasClass('autoprint')) { + window.print(); + } + }).on('hidden.bs.modal', function() { + $('.modal-dialog', this).removeClass('focused'); + $('body').removeClass('modalprinter'); + }); + + $('.printModal').click(function() { + window.print(); + }); }); </script> + [% END %] [% INCLUDE 'intranet-bottom.inc' %] -- 2.20.1