From d9f0f76b1380fcff823a586ea78737599b494250 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.
---
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 5342668ba3..67c795f377 100644
--- a/C4/Circulation.pm
+++ b/C4/Circulation.pm
@@ -2311,6 +2311,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 7396db6f73..70f88fcd64 100644
--- a/Koha/Item.pm
+++ b/Koha/Item.pm
@@ -397,6 +397,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 045a30ee31..2d7ea42346 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 0000000000..ca6dc1d1ff
--- /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 0000000000..c88822b4e9
--- /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 6c2a33bc6c..90317e316d 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 27e5c305da..1caf9a4672 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>Bundle contents list updated</p>
+ <p><a class="btn btn-default btn-xs" role="button" data-toggle="modal" href="#bundleContentsModal"><i class="fa fa-eye" aria-hidden="true"></i> View updated contents list</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