Bugzilla – Attachment 124319 Details for
Bug 28854
Add ability to create bundles of items for circulation
Home
|
New
|
Browse
|
Search
|
[?]
|
Reports
|
Help
|
New Account
|
Log In
[x]
|
Forgot Password
Login:
[x]
[patch]
Bug 28854: Add ability to create and circulate item bundles
Bug-28854-Add-ability-to-create-and-circulate-item.patch (text/plain), 86.41 KB, created by
Martin Renvoize (ashimema)
on 2021-09-01 10:54:49 UTC
(
hide
)
Description:
Bug 28854: Add ability to create and circulate item bundles
Filename:
MIME Type:
Creator:
Martin Renvoize (ashimema)
Created:
2021-09-01 10:54:49 UTC
Size:
86.41 KB
patch
obsolete
>From 4b4e33d7ba81627c0437d3f9815e349fc2fdc822 Mon Sep 17 00:00:00 2001 >From: Martin Renvoize <martin.renvoize@ptfs-europe.com> >Date: Tue, 17 Aug 2021 17:04:51 +0100 >Subject: [PATCH] Bug 28854: Add ability to create and circulate item bundles > >This patch adds the ability to attach item bundles to biblio records. Item >bundles can then be circulated as with normal items. > >Upon attaching an item to one of these item bundles, the item is automatically >marked as not for loan at it's original host biblio record. > >When returning a bundle, the list of items that are part of this bundle >will be displayed, and a verification form is exposed requireing the >librarian to scan item barcodes to verify their presence. Should a bundle >be found to have items missing they will be marked as lost. > >Bundles can also be used in the inventory tool as a filter. > >Test plan: >0. Apply the patch, run updatedatabase + update_dbix_class_files >1. Create a new bundle record. >1a. Create a biblio record with leader position 7 set to 'c' for collection. >1b. Create an item for this biblio record. >1c. You should now see a new 'Bundle' button next to the item in the holdings > table. > Test: Verify that this 'Bundle' button only appears for biblio's where leader > position 7 is not 'c' for collection >1d. Use the new 'Bundle' button to trigger a modal allowing you to attach items > to the bundle. > Test: Enter a non-existant barcode, submit and confirm you recieve an error message. > Test: Enter an existing barcode, submit and note the success message. > Test: Enter the same barcode as above, submit and note the warning message. > Test: Add a second item to the bib and try bundling one of the above > items into this second item, note the error message. >2. Open a new tab and look at the holdings data for one of the items you attached > to a bundle in step 1. > Test: Verify that the holdings status displays 'Not for loan'. > Test: Verify that the holdings status show which bundle the item has been > attached to. >3. Perform a checkout on the first bundle item. > Test: Checkout should proceed as normal, obeying the circulation > rules for the item type you chose to convert into a bundle. >4. Perform a checkin on the first bundle item. > Test: You should be presented with a modal that contains a list of items > contained in the bundle and a test box for entering item barcodes. > Test: Enter some of the barcodes for items in the bundle into the box and submit. > The bundle item should have been marked as returned, you should also > have been notified of missing items and have the ability to print an > updated contents list. > Test: Verify that the items you did not enter barcodes for are now > marked as lost. >--- > C4/Accounts.pm | 7 +- > C4/Items.pm | 6 + > Koha/Biblio.pm | 20 ++ > Koha/Item.pm | 199 +++++++++++++++ > Koha/Item/Bundle.pm | 68 +++++ > Koha/Item/Bundles.pm | 63 +++++ > Koha/REST/V1/Items.pm | 120 +++++++++ > Koha/Schema/Result/Item.pm | 34 ++- > Koha/Schema/Result/ItemBundle.pm | 113 +++++++++ > api/v1/swagger/definitions.json | 3 + > api/v1/swagger/definitions/bundle_link.json | 14 + > api/v1/swagger/definitions/item.json | 4 + > api/v1/swagger/paths.json | 6 + > api/v1/swagger/paths/items.json | 240 ++++++++++++++++++ > catalogue/detail.pl | 18 ++ > circ/returns.pl | 74 +++++- > .../atomicupdate/bug-24023-items-bundles.perl | 55 ++++ > installer/data/mysql/kohastructure.sql | 37 +++ > .../prog/css/src/staff-global.scss | 4 + > .../prog/en/includes/item-status.inc | 84 ++++++ > .../en/includes/modals/bundle_contents.inc | 35 +++ > .../admin/preferences/cataloguing.pref | 4 + > .../admin/preferences/circulation.pref | 4 + > .../prog/en/modules/catalogue/detail.tt | 210 ++++++++++++++- > .../prog/en/modules/circ/returns.tt | 117 +++++++++ > .../prog/en/modules/tools/inventory.tt | 20 +- > .../bootstrap/en/modules/opac-detail.tt | 92 ++++++- > opac/opac-detail.pl | 10 + > tools/inventory.pl | 10 + > 29 files changed, 1661 insertions(+), 10 deletions(-) > create mode 100644 Koha/Item/Bundle.pm > create mode 100644 Koha/Item/Bundles.pm > create mode 100644 Koha/Schema/Result/ItemBundle.pm > create mode 100644 api/v1/swagger/definitions/bundle_link.json > create mode 100644 installer/data/mysql/atomicupdate/bug-24023-items-bundles.perl > 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/Accounts.pm b/C4/Accounts.pm >index ad0d34f184..84ebbcd225 100644 >--- a/C4/Accounts.pm >+++ b/C4/Accounts.pm >@@ -70,7 +70,8 @@ FIXME : if no replacement price, borrower just doesn't get charged? > sub chargelostitem { > my $dbh = C4::Context->dbh(); > my ($borrowernumber, $itemnumber, $amount, $description) = @_; >- my $itype = Koha::ItemTypes->find({ itemtype => Koha::Items->find($itemnumber)->effective_itemtype() }); >+ my $item = Koha::Items->find($itemnumber); >+ my $itype = Koha::ItemTypes->find({ itemtype => $item->effective_itemtype() }); > my $replacementprice = $amount; > my $defaultreplacecost = $itype->defaultreplacecost; > my $processfee = $itype->processfee; >@@ -80,6 +81,10 @@ sub chargelostitem { > $replacementprice = $defaultreplacecost; > } > my $checkout = Koha::Checkouts->find({ itemnumber => $itemnumber }); >+ if ( !$checkout && $item->in_bundle ) { >+ my $host = $item->bundle_host; >+ $checkout = $host->checkout; >+ } > my $issue_id = $checkout ? $checkout->issue_id : undef; > > my $account = Koha::Account->new({ patron_id => $borrowernumber }); >diff --git a/C4/Items.pm b/C4/Items.pm >index 467f695020..5413114430 100644 >--- a/C4/Items.pm >+++ b/C4/Items.pm >@@ -526,6 +526,7 @@ sub GetItemsForInventory { > my $minlocation = $parameters->{'minlocation'} // ''; > my $maxlocation = $parameters->{'maxlocation'} // ''; > my $class_source = $parameters->{'class_source'} // C4::Context->preference('DefaultClassificationSource'); >+ my $items_bundle = $parameters->{'items_bundle'} // ''; > my $location = $parameters->{'location'} // ''; > my $itemtype = $parameters->{'itemtype'} // ''; > my $ignoreissued = $parameters->{'ignoreissued'} // ''; >@@ -572,6 +573,11 @@ sub GetItemsForInventory { > push @bind_params, $max_cnsort; > } > >+ if ($items_bundle) { >+ push @where_strings, 'items.itemnumber IN (SELECT itemnumber FROM items_bundle_item WHERE items_bundle_id = ?)'; >+ push @bind_params, $items_bundle; >+ } >+ > if ($datelastseen) { > $datelastseen = output_pref({ str => $datelastseen, dateformat => 'iso', dateonly => 1 }); > push @where_strings, '(datelastseen < ? OR datelastseen IS NULL)'; >diff --git a/Koha/Biblio.pm b/Koha/Biblio.pm >index 26ee7abd96..7e57279927 100644 >--- a/Koha/Biblio.pm >+++ b/Koha/Biblio.pm >@@ -37,6 +37,7 @@ use Koha::Biblio::Metadatas; > use Koha::Biblioitems; > use Koha::CirculationRules; > use Koha::Item::Transfer::Limits; >+use Koha::Item::Bundles; > use Koha::Items; > use Koha::Libraries; > use Koha::Suggestions; >@@ -419,6 +420,25 @@ sub items { > return Koha::Items->_new_from_dbic( $items_rs ); > } > >+=head3 item_bundles >+ >+ my $bundles = $biblio->item_bundles(); >+ >+Returns the related Koha::Items limited to items that are bundles for this biblio; >+ >+=cut >+ >+sub item_bundles { >+ my ($self) = @_; >+ >+ my $items_rs = $self->_result->items( >+ { 'item_bundles_hosts.host' => { '!=' => undef } }, >+ { join => 'item_bundles_hosts', collapse => 1 } >+ ); >+ >+ return Koha::Item::Bundles->_new_from_dbic($items_rs); >+} >+ > =head3 itemtype > > my $itemtype = $biblio->itemtype(); >diff --git a/Koha/Item.pm b/Koha/Item.pm >index ca17ed0637..354e003f36 100644 >--- a/Koha/Item.pm >+++ b/Koha/Item.pm >@@ -21,6 +21,7 @@ use Modern::Perl; > > use List::MoreUtils qw( any ); > use Data::Dumper qw( Dumper ); >+use Try::Tiny qw( catch try ); > > use Koha::Database; > use Koha::DateUtils qw( dt_from_string ); >@@ -393,6 +394,39 @@ 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 loss_checkout >+ >+ my $loss_checkout = $item->loss_checkout; >+ >+Return the old checkout from which this item was marked as lost >+ >+=cut >+ >+sub loss_checkout { >+ my ( $self ) = @_; >+ my $items_lost_issue_rs = $self->_result->items_lost_issue; >+ return unless $items_lost_issue_rs; >+ my $issue_rs = $items_lost_issue_rs->issue; >+ return unless $issue_rs; >+ return Koha::Old::Checkout->_new_from_dbic( $issue_rs ); >+} >+ > =head3 holds > > my $holds = $item->holds(); >@@ -1172,6 +1206,171 @@ sub itemtype { > return Koha::ItemTypes->find( $self->effective_itemtype ); > } > >+ >+=head3 bundle_items >+ >+ my $bundle_items = $item->bundle_items; >+ >+Returns the items associated with this bundle >+ >+=cut >+ >+sub bundle_items { >+ my ($self) = @_; >+ >+ if ( !$self->{_bundle_items_cached} ) { >+ my $bundle_items = Koha::Items->search( >+ { 'item_bundles_item.host' => $self->itemnumber }, >+ { join => 'item_bundles_item' } ); >+ $self->{_bundle_items} = $bundle_items; >+ $self->{_bundle_items_cached} = 1; >+ } >+ >+ return $self->{_bundle_items}; >+} >+ >+=head3 is_bundle >+ >+ my $is_bundle = $item->is_bundle; >+ >+Returns whether the item is a bundle or not >+ >+=cut >+ >+sub is_bundle { >+ my ($self) = @_; >+ return $self->bundle_items->count ? 1 : 0; >+} >+ >+=head3 bundle_host >+ >+ my $bundle = $item->bundle_host; >+ >+Returns the bundle item this item is attached to >+ >+=cut >+ >+sub bundle_host { >+ my ($self) = @_; >+ >+ if ( !$self->{_bundle_host_cached} ) { >+ my $bundle_item_rs = $self->_result->item_bundles_item; >+ $self->{_bundle_host} = >+ $bundle_item_rs >+ ? Koha::Item->_new_from_dbic($bundle_item_rs->host) >+ : undef; >+ $self->{_bundle_host_cached} = 1; >+ } >+ >+ return $self->{_bundle_host}; >+} >+ >+=head3 in_bundle >+ >+ my $in_bundle = $item->in_bundle; >+ >+Returns whether this item is currently in a bundle >+ >+=cut >+ >+sub in_bundle { >+ my ($self) = @_; >+ return $self->bundle_host ? 1 : 0; >+} >+ >+=head3 add_to_bundle >+ >+ my $link = $item->add_to_bundle($bundle_item); >+ >+Adds the bundle_item passed to this item >+ >+=cut >+ >+sub add_to_bundle { >+ my ( $self, $bundle_item ) = @_; >+ >+ my $schema = Koha::Database->new->schema; >+ >+ my $BundleNotLoanValue = C4::Context->preference('BundleNotLoanValue'); >+ >+ try { >+ $schema->txn_do( >+ sub { >+ $self->_result->add_to_item_bundles_hosts( >+ { item => $bundle_item->itemnumber } ); >+ >+ $bundle_item->notforloan($BundleNotLoanValue)->store(); >+ } >+ ); >+ } >+ catch { >+ >+ # FIXME: See if we can move the below copy/paste from Koha::Object::store into it's own class and catch at a lower level in the Schema instantiation.. take inspiration fro DBIx::Error >+ if ( ref($_) eq 'DBIx::Class::Exception' ) { >+ warn $_->{msg}; >+ if ( $_->{msg} =~ /Cannot add or update a child row: a foreign key constraint fails/ ) { >+ # FK constraints >+ # FIXME: MySQL error, if we support more DB engines we should implement this for each >+ if ( $_->{msg} =~ /FOREIGN KEY \(`(?<column>.*?)`\)/ ) { >+ Koha::Exceptions::Object::FKConstraint->throw( >+ error => 'Broken FK constraint', >+ broken_fk => $+{column} >+ ); >+ } >+ } >+ elsif ( >+ $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?<key>.*?)'/ ) >+ { >+ Koha::Exceptions::Object::DuplicateID->throw( >+ error => 'Duplicate ID', >+ duplicate_id => $+{key} >+ ); >+ } >+ elsif ( $_->{msg} =~ >+/Incorrect (?<type>\w+) value: '(?<value>.*)' for column \W?(?<property>\S+)/ >+ ) >+ { # The optional \W in the regex might be a quote or backtick >+ my $type = $+{type}; >+ my $value = $+{value}; >+ my $property = $+{property}; >+ $property =~ s/['`]//g; >+ Koha::Exceptions::Object::BadValue->throw( >+ type => $type, >+ value => $value, >+ property => $property =~ /(\w+\.\w+)$/ >+ ? $1 >+ : $property >+ , # results in table.column without quotes or backtics >+ ); >+ } >+ >+ # Catch-all for foreign key breakages. It will help find other use cases >+ $_->rethrow(); >+ } >+ else { >+ $_; >+ } >+ }; >+} >+ >+=head3 remove_from_bundle >+ >+Remove this item from any bundle it may have been attached to. >+ >+=cut >+ >+sub remove_from_bundle { >+ my ($self) = @_; >+ >+ my $bundle_item_rs = $self->_result->item_bundles_item; >+ if ( $bundle_item_rs ) { >+ $bundle_item_rs->delete; >+ $self->notforloan(0)->store(); >+ return 1; >+ } >+ return 0; >+} >+ > =head2 Internal methods > > =head3 _after_item_action_hooks >diff --git a/Koha/Item/Bundle.pm b/Koha/Item/Bundle.pm >new file mode 100644 >index 0000000000..6a32bb1915 >--- /dev/null >+++ b/Koha/Item/Bundle.pm >@@ -0,0 +1,68 @@ >+package Koha::Item::Bundle; >+ >+# This file is part of Koha. >+# >+# Koha is free software; you can redistribute it and/or modify it >+# under the terms of the GNU General Public License as published by >+# the Free Software Foundation; either version 3 of the License, or >+# (at your option) any later version. >+# >+# Koha is distributed in the hope that it will be useful, but >+# WITHOUT ANY WARRANTY; without even the implied warranty of >+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the >+# GNU General Public License for more details. >+# >+# You should have received a copy of the GNU General Public License >+# along with Koha; if not, see <http://www.gnu.org/licenses>. >+ >+use Modern::Perl; >+ >+use Koha::Database; >+ >+use base qw(Koha::Item); >+ >+=head1 NAME >+ >+Koha::Item::Bundle - Koha Item Bundle Object class >+ >+=head1 API >+ >+=head2 Class Methods >+ >+=head3 host >+ >+ my $host = $bundle->host; >+ >+Returns the associated host item for this bundle. >+ >+=cut >+ >+sub host { >+ my ($self) = @_; >+ my $host_rs = $self->_result->itemnumber; >+ return Koha::Item->_new_from_dbic($host_rs); >+} >+ >+=head3 items >+ >+ my $items = $bundle->items; >+ >+Returns the associated items attached to this bundle. >+ >+=cut >+ >+sub items { >+ my ($self) = @_; >+ my $items_rs = $self->_result->itemnumbers; >+ return Koha::Items->_new_from_dbic($items_rs); >+} >+ >+=head3 type >+ >+=cut >+ >+sub _type { >+ return 'Item'; >+} >+ >+1; >diff --git a/Koha/Item/Bundles.pm b/Koha/Item/Bundles.pm >new file mode 100644 >index 0000000000..93e23abdaa >--- /dev/null >+++ b/Koha/Item/Bundles.pm >@@ -0,0 +1,63 @@ >+package Koha::Item::Bundles; >+ >+# This file is part of Koha. >+# >+# Koha is free software; you can redistribute it and/or modify it >+# under the terms of the GNU General Public License as published by >+# the Free Software Foundation; either version 3 of the License, or >+# (at your option) any later version. >+# >+# Koha is distributed in the hope that it will be useful, but >+# WITHOUT ANY WARRANTY; without even the implied warranty of >+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the >+# GNU General Public License for more details. >+# >+# You should have received a copy of the GNU General Public License >+# along with Koha; if not, see <http://www.gnu.org/licenses>. >+ >+use Modern::Perl; >+ >+use Koha::Database; >+use Koha::Item::Bundle; >+ >+use base qw(Koha::Items); >+ >+=head1 NAME >+ >+Koha::Item::Bundles - Koha Item Bundles Object set class >+ >+=head1 API >+ >+=head2 Class Methods >+ >+=head3 search >+ >+ my $bundles = Koha::Item::Bundles->search( $where, $attr ); >+ >+Return a Koha::Item::Bundles set. >+ >+=cut >+ >+sub search { >+ my ($self , $where, $attr ) = @_; >+ >+ my $rs = $self->SUPER::search( >+ { 'item_bundles_hosts.host' => { '!=' => undef } }, >+ { join => 'item_bundles_hosts', collapse => 1 } >+ ); >+ return $rs->search( $where, $attr ); >+} >+ >+=head3 type >+ >+=cut >+ >+sub _type { >+ return 'Item'; >+} >+ >+sub object_class { >+ return 'Koha::Item::Bundle'; >+} >+ >+1; >diff --git a/Koha/REST/V1/Items.pm b/Koha/REST/V1/Items.pm >index 1887bce514..556cdb048a 100644 >--- a/Koha/REST/V1/Items.pm >+++ b/Koha/REST/V1/Items.pm >@@ -152,4 +152,124 @@ sub pickup_locations { > }; > } > >+=head3 bundled_items >+ >+Controller function that handles bundled_items Koha::Item objects >+ >+=cut >+ >+sub bundled_items { >+ my $c = shift->openapi->valid_input or return; >+ >+ my $item_id = $c->validation->param('item_id'); >+ my $item = Koha::Items->find( $item_id ); >+ >+ unless ($item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Item not found" } >+ ); >+ } >+ >+ return try { >+ my $items_set = $item->bundle_items; >+ my $items = $c->objects->search( $items_set ); >+ return $c->render( >+ status => 200, >+ openapi => $items >+ ); >+ } >+ catch { >+ $c->unhandled_exception($_); >+ }; >+} >+ >+=head3 add_to_bundle >+ >+Controller function that handles adding items to this bundle >+ >+=cut >+ >+sub add_to_bundle { >+ my $c = shift->openapi->valid_input or return; >+ >+ my $item_id = $c->validation->param('item_id'); >+ my $item = Koha::Items->find( $item_id ); >+ >+ unless ($item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Item not found" } >+ ); >+ } >+ >+ >+ my $bundle_item_id = $c->validation->param('body')->{'external_id'}; >+ my $bundle_item = Koha::Items->find( { barcode => $bundle_item_id } ); >+ >+ unless ($bundle_item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Bundle item not found" } >+ ); >+ } >+ >+ return try { >+ my $link = $item->add_to_bundle($bundle_item); >+ return $c->render( >+ status => 201, >+ openapi => $bundle_item >+ ); >+ } >+ catch { >+ if ( ref($_) eq 'Koha::Exceptions::Object::DuplicateID' ) { >+ return $c->render( >+ status => 409, >+ openapi => { >+ error => 'Item is already bundled', >+ key => $_->duplicate_id >+ } >+ ); >+ } >+ else { >+ $c->unhandled_exception($_); >+ } >+ }; >+} >+ >+=head3 remove_from_bundle >+ >+Controller function that handles removing items from this bundle >+ >+=cut >+ >+sub remove_from_bundle { >+ my $c = shift->openapi->valid_input or return; >+ >+ my $item_id = $c->validation->param('item_id'); >+ my $item = Koha::Items->find( $item_id ); >+ >+ unless ($item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Item not found" } >+ ); >+ } >+ >+ my $bundle_item_id = $c->validation->param('body')->{'item_id'}; >+ my $bundle_item = Koha::Items->find( { itemnumber => $bundle_item_id } ); >+ >+ unless ($bundle_item) { >+ return $c->render( >+ status => 404, >+ openapi => { error => "Bundle item not found" } >+ ); >+ } >+ >+ $bundle_item->remove_from_bundle; >+ return $c->render( >+ status => 204, >+ ); >+} >+ > 1; >diff --git a/Koha/Schema/Result/Item.pm b/Koha/Schema/Result/Item.pm >index 1d818126a4..33b599473c 100644 >--- a/Koha/Schema/Result/Item.pm >+++ b/Koha/Schema/Result/Item.pm >@@ -729,6 +729,36 @@ __PACKAGE__->might_have( > { cascade_copy => 0, cascade_delete => 0 }, > ); > >+=head2 item_bundles_hosts >+ >+Type: has_many >+ >+Related object: L<Koha::Schema::Result::ItemBundle> >+ >+=cut >+ >+__PACKAGE__->has_many( >+ "item_bundles_hosts", >+ "Koha::Schema::Result::ItemBundle", >+ { "foreign.host" => "self.itemnumber" }, >+ { cascade_copy => 0, cascade_delete => 0 }, >+); >+ >+=head2 item_bundles_item >+ >+Type: might_have >+ >+Related object: L<Koha::Schema::Result::ItemBundle> >+ >+=cut >+ >+__PACKAGE__->might_have( >+ "item_bundles_item", >+ "Koha::Schema::Result::ItemBundle", >+ { "foreign.item" => "self.itemnumber" }, >+ { cascade_copy => 0, cascade_delete => 0 }, >+); >+ > =head2 items_last_borrower > > Type: might_have >@@ -850,8 +880,8 @@ __PACKAGE__->has_many( > ); > > >-# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-01-21 13:39:29 >-# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:U5Tm2JfUnfhACRDJ4SpFgQ >+# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-08-10 13:47:56 >+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:zKbxMr6eySAbQsgtVP7kVg > > __PACKAGE__->belongs_to( biblioitem => "Koha::Schema::Result::Biblioitem", "biblioitemnumber" ); > >diff --git a/Koha/Schema/Result/ItemBundle.pm b/Koha/Schema/Result/ItemBundle.pm >new file mode 100644 >index 0000000000..590061c922 >--- /dev/null >+++ b/Koha/Schema/Result/ItemBundle.pm >@@ -0,0 +1,113 @@ >+use utf8; >+package Koha::Schema::Result::ItemBundle; >+ >+# Created by DBIx::Class::Schema::Loader >+# DO NOT MODIFY THE FIRST PART OF THIS FILE >+ >+=head1 NAME >+ >+Koha::Schema::Result::ItemBundle >+ >+=cut >+ >+use strict; >+use warnings; >+ >+use base 'DBIx::Class::Core'; >+ >+=head1 TABLE: C<item_bundles> >+ >+=cut >+ >+__PACKAGE__->table("item_bundles"); >+ >+=head1 ACCESSORS >+ >+=head2 item >+ >+ data_type: 'integer' >+ is_foreign_key: 1 >+ is_nullable: 0 >+ >+=head2 host >+ >+ data_type: 'integer' >+ is_foreign_key: 1 >+ is_nullable: 0 >+ >+=cut >+ >+__PACKAGE__->add_columns( >+ "item", >+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, >+ "host", >+ { data_type => "integer", is_foreign_key => 1, is_nullable => 0 }, >+); >+ >+=head1 PRIMARY KEY >+ >+=over 4 >+ >+=item * L</host> >+ >+=item * L</item> >+ >+=back >+ >+=cut >+ >+__PACKAGE__->set_primary_key("host", "item"); >+ >+=head1 UNIQUE CONSTRAINTS >+ >+=head2 C<item_bundles_uniq_1> >+ >+=over 4 >+ >+=item * L</item> >+ >+=back >+ >+=cut >+ >+__PACKAGE__->add_unique_constraint("item_bundles_uniq_1", ["item"]); >+ >+=head1 RELATIONS >+ >+=head2 host >+ >+Type: belongs_to >+ >+Related object: L<Koha::Schema::Result::Item> >+ >+=cut >+ >+__PACKAGE__->belongs_to( >+ "host", >+ "Koha::Schema::Result::Item", >+ { itemnumber => "host" }, >+ { is_deferrable => 1, on_delete => "CASCADE", on_update => "CASCADE" }, >+); >+ >+=head2 item >+ >+Type: belongs_to >+ >+Related object: L<Koha::Schema::Result::Item> >+ >+=cut >+ >+__PACKAGE__->belongs_to( >+ "item", >+ "Koha::Schema::Result::Item", >+ { itemnumber => "item" }, >+ { is_deferrable => 1, on_delete => "CASCADE", on_update => "CASCADE" }, >+); >+ >+ >+# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-08-10 13:47:56 >+# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:QXlgF8iwZUFSsg+Eo5kNUw >+ >+ >+# You can replace this text with custom code or comments, and it will be preserved on regeneration >+1; >diff --git a/api/v1/swagger/definitions.json b/api/v1/swagger/definitions.json >index 02dbc95c01..f5d26e7ba4 100644 >--- a/api/v1/swagger/definitions.json >+++ b/api/v1/swagger/definitions.json >@@ -5,6 +5,9 @@ > "basket": { > "$ref": "definitions/basket.json" > }, >+ "bundle_link": { >+ "$ref": "definitions/bundle_link.json" >+ }, > "cashup": { > "$ref": "definitions/cashup.json" > }, >diff --git a/api/v1/swagger/definitions/bundle_link.json b/api/v1/swagger/definitions/bundle_link.json >new file mode 100644 >index 0000000000..7b1274dfe4 >--- /dev/null >+++ b/api/v1/swagger/definitions/bundle_link.json >@@ -0,0 +1,14 @@ >+{ >+ "type": "object", >+ "properties": { >+ "item_id": { >+ "type": ["integer", "null"], >+ "description": "Internal item identifier" >+ }, >+ "external_id": { >+ "type": ["string", "null"], >+ "description": "Item barcode" >+ } >+ }, >+ "additionalProperties": false >+} >diff --git a/api/v1/swagger/definitions/item.json b/api/v1/swagger/definitions/item.json >index 14c110714e..203ca81289 100644 >--- a/api/v1/swagger/definitions/item.json >+++ b/api/v1/swagger/definitions/item.json >@@ -180,6 +180,10 @@ > "exclude_from_local_holds_priority": { > "type": "boolean", > "description": "Exclude this item from local holds priority." >+ }, >+ "biblio": { >+ }, >+ "checkout": { > } > }, > "additionalProperties": false, >diff --git a/api/v1/swagger/paths.json b/api/v1/swagger/paths.json >index cdcd0368c5..73fe94348c 100644 >--- a/api/v1/swagger/paths.json >+++ b/api/v1/swagger/paths.json >@@ -83,6 +83,12 @@ > "/items/{item_id}": { > "$ref": "paths/items.json#/~1items~1{item_id}" > }, >+ "/items/{item_id}/bundled_items": { >+ "$ref": "paths/items.json#/~1items~1{item_id}~1bundled_items" >+ }, >+ "/items/{item_id}/bundled_items/item": { >+ "$ref": "paths/items.json#/~1items~1{item_id}~1bundled_items~1item" >+ }, > "/items/{item_id}/pickup_locations": { > "$ref": "paths/items.json#/~1items~1{item_id}~1pickup_locations" > }, >diff --git a/api/v1/swagger/paths/items.json b/api/v1/swagger/paths/items.json >index 7c18f625ee..073977ab19 100644 >--- a/api/v1/swagger/paths/items.json >+++ b/api/v1/swagger/paths/items.json >@@ -127,6 +127,246 @@ > } > } > }, >+ "/items/{item_id}/bundled_items": { >+ "get": { >+ "x-mojo-to": "Items#bundled_items", >+ "operationId": "bundledItems", >+ "tags": [ >+ "items" >+ ], >+ "summary": "List bundled items", >+ "parameters": [ >+ { >+ "$ref": "../parameters.json#/item_id_pp" >+ }, >+ { >+ "name": "external_id", >+ "in": "query", >+ "description": "Search on the item's barcode", >+ "required": false, >+ "type": "string" >+ }, >+ { >+ "$ref": "../parameters.json#/match" >+ }, >+ { >+ "$ref": "../parameters.json#/order_by" >+ }, >+ { >+ "$ref": "../parameters.json#/page" >+ }, >+ { >+ "$ref": "../parameters.json#/per_page" >+ }, >+ { >+ "$ref": "../parameters.json#/q_param" >+ }, >+ { >+ "$ref": "../parameters.json#/q_body" >+ }, >+ { >+ "$ref": "../parameters.json#/q_header" >+ } >+ ], >+ "consumes": [ >+ "application/json" >+ ], >+ "produces": [ >+ "application/json" >+ ], >+ "responses": { >+ "200": { >+ "description": "A list of item", >+ "schema": { >+ "type": "array", >+ "items": { >+ "$ref": "../definitions.json#/item" >+ } >+ } >+ }, >+ "401": { >+ "description": "Authentication required", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "403": { >+ "description": "Access forbidden", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "500": { >+ "description": "Internal server error", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "503": { >+ "description": "Under maintenance", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ } >+ }, >+ "x-koha-authorization": { >+ "permissions": { >+ "catalogue": "1" >+ } >+ }, >+ "x-koha-embed": [ >+ "biblio", >+ "checkout" >+ ] >+ } >+ }, >+ "/items/{item_id}/bundled_items/item": { >+ "post": { >+ "x-mojo-to": "Items#add_to_bundle", >+ "operationId": "addToBundle", >+ "tags": ["items"], >+ "summary": "Add item to bundle", >+ "parameters": [{ >+ "$ref": "../parameters.json#/item_id_pp" >+ }, >+ { >+ "name": "body", >+ "in": "body", >+ "description": "A JSON object containing information about the new bundle link", >+ "required": true, >+ "schema": { >+ "$ref": "../definitions.json#/bundle_link" >+ } >+ } >+ ], >+ "consumes": ["application/json"], >+ "produces": ["application/json"], >+ "responses": { >+ "201": { >+ "description": "A successfully created bundle link", >+ "schema": { >+ "items": { >+ "$ref": "../definitions.json#/item" >+ } >+ } >+ }, >+ "400": { >+ "description": "Bad parameter", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "401": { >+ "description": "Authentication required", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "403": { >+ "description": "Access forbidden", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "404": { >+ "description": "Resource not found", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "409": { >+ "description": "Conflict in creating resource", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "500": { >+ "description": "Internal server error", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "503": { >+ "description": "Under maintenance", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ } >+ }, >+ "x-koha-authorization": { >+ "permissions": { >+ "catalogue": 1 >+ } >+ } >+ }, >+ "delete": { >+ "x-mojo-to": "Items#remove_from_bundle", >+ "operationId": "removeFromBundle", >+ "tags": ["items"], >+ "summary": "Remove item from bundle", >+ "parameters": [{ >+ "$ref": "../parameters.json#/item_id_pp" >+ }, >+ { >+ "name": "body", >+ "in": "body", >+ "description": "A JSON object containing information about the bundle link to remove", >+ "required": true, >+ "schema": { >+ "$ref": "../definitions.json#/bundle_link" >+ } >+ } >+ ], >+ "consumes": ["application/json"], >+ "produces": ["application/json"], >+ "responses": { >+ "204": { >+ "description": "Bundle link deleted" >+ }, >+ "400": { >+ "description": "Bad parameter", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "401": { >+ "description": "Authentication required", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "403": { >+ "description": "Access forbidden", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "404": { >+ "description": "Resource not found", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "500": { >+ "description": "Internal server error", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ }, >+ "503": { >+ "description": "Under maintenance", >+ "schema": { >+ "$ref": "../definitions.json#/error" >+ } >+ } >+ }, >+ "x-koha-authorization": { >+ "permissions": { >+ "catalogue": 1 >+ } >+ } >+ } >+ }, > "/items/{item_id}/pickup_locations": { > "get": { > "x-mojo-to": "Items#pickup_locations", >diff --git a/catalogue/detail.pl b/catalogue/detail.pl >index 0cb204ced9..a12274a997 100755 >--- a/catalogue/detail.pl >+++ b/catalogue/detail.pl >@@ -49,6 +49,7 @@ use Koha::AuthorisedValues; > use Koha::Biblios; > use Koha::CoverImages; > use Koha::Illrequests; >+use Koha::Database; > use Koha::Items; > use Koha::ItemTypes; > use Koha::Patrons; >@@ -56,6 +57,8 @@ use Koha::Virtualshelves; > use Koha::Plugins; > use Koha::SearchEngine::Search; > >+my $schema = Koha::Database->new()->schema(); >+ > my $query = CGI->new(); > > my $analyze = $query->param('analyze'); >@@ -203,6 +206,11 @@ if (@hostitems){ > > my $dat = &GetBiblioData($biblionumber); > >+#is biblio a collection >+my $leader = $record->leader(); >+$dat->{collection} = ( substr($leader,7,1) eq 'c' ) ? 1 : 0; >+ >+ > #coping with subscriptions > my $subscriptionsnumber = CountSubscriptionFromBiblionumber($biblionumber); > my @subscriptions = SearchSubscriptions({ biblionumber => $biblionumber, orderby => 'title' }); >@@ -395,6 +403,15 @@ foreach my $item (@items) { > $item->{cover_images} = $item_object->cover_images; > } > >+ if ($item_object->is_bundle) { >+ $itemfields{bundles} = 1; >+ $item->{is_bundle} = 1; >+ } >+ >+ if ($item_object->in_bundle) { >+ $item->{bundle_host} = $item_object->bundle_host; >+ } >+ > if ($currentbranch and C4::Context->preference('SeparateHoldings')) { > if ($itembranchcode and $itembranchcode eq $currentbranch) { > push @itemloop, $item; >@@ -451,6 +468,7 @@ $template->param( > itemdata_copynumber => $itemfields{copynumber}, > itemdata_stocknumber => $itemfields{stocknumber}, > itemdata_publisheddate => $itemfields{publisheddate}, >+ itemdata_bundles => $itemfields{bundles}, > volinfo => $itemfields{enumchron}, > itemdata_itemnotes => $itemfields{itemnotes}, > itemdata_nonpublicnotes => $itemfields{itemnotes_nonpublic}, >diff --git a/circ/returns.pl b/circ/returns.pl >index 914d5de35c..44166e12b9 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; >@@ -272,6 +271,7 @@ if ($barcode) { > > my $checkout = $item->checkout; > my $biblio = $item->biblio; >+ > $template->param( > title => $biblio->title, > returnbranch => $returnbranch, >@@ -282,6 +282,7 @@ if ($barcode) { > issue => $checkout, > item => $item, > ); >+ > } # FIXME else we should not call AddReturn but set BadBarcode directly instead > > my %input = ( >@@ -301,10 +302,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 +354,60 @@ 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(); >+ $bundle_item->_result->update_or_create_related( >+ 'items_lost_issue', { issue_id => $checkout->issue_id } ); >+ push @missing_items, $bundle_item; >+ if ( C4::Context->preference('WhenLostChargeReplacementFee') ) { >+ C4::Accounts::chargelostitem( >+ $checkout->borrowernumber, >+ $bundle_item->itemnumber, >+ $bundle_item->replacementprice, >+ sprintf( "%s %s %s", >+ $bundle_item->biblio->title || q{}, >+ $bundle_item->barcode || q{}, >+ $bundle_item->itemcallnumber || q{}, >+ ), >+ ); >+ } >+ } >+ $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 +418,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 ); > >diff --git a/installer/data/mysql/atomicupdate/bug-24023-items-bundles.perl b/installer/data/mysql/atomicupdate/bug-24023-items-bundles.perl >new file mode 100644 >index 0000000000..550478f8d0 >--- /dev/null >+++ b/installer/data/mysql/atomicupdate/bug-24023-items-bundles.perl >@@ -0,0 +1,55 @@ >+$DBversion = 'XXX'; >+if( CheckVersion( $DBversion ) ) { >+ if( !TableExists( 'item_bundles' ) ) { >+ $dbh->do(q{ >+ CREATE TABLE `item_bundles` ( >+ `item` int(11) NOT NULL, >+ `host` int(11) NOT NULL, >+ PRIMARY KEY (`host`, `item`), >+ UNIQUE KEY `item_bundles_uniq_1` (`item`), >+ CONSTRAINT `item_bundles_ibfk_1` FOREIGN KEY (`item`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE, >+ CONSTRAINT `item_bundles_ibfk_2` FOREIGN KEY (`host`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE >+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci >+ }); >+ } >+ >+ if( !TableExists( 'items_lost_issue' ) ) { >+ $dbh->do(q{ >+ CREATE TABLE `items_lost_issue` ( >+ `id` int(11) NOT NULL AUTO_INCREMENT, >+ `itemnumber` int(11) NOT NULL, >+ `issue_id` int(11) DEFAULT NULL, >+ `created_on` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), >+ PRIMARY KEY (`id`), >+ UNIQUE KEY `itemnumber` (`itemnumber`), >+ KEY `issue_id` (`issue_id`), >+ CONSTRAINT `items_lost_issue_ibfk_1` FOREIGN KEY (`itemnumber`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE, >+ CONSTRAINT `items_lost_issue_ibfk_2` FOREIGN KEY (`issue_id`) REFERENCES `old_issues` (`issue_id`) ON DELETE SET NULL ON UPDATE CASCADE >+ ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci >+ }); >+ } >+ >+ my ($lost_val) = $dbh->selectrow_array( "SELECT MAX(authorised_value) FROM authorised_values WHERE category = 'LOST'", {} ); >+ $lost_val++; >+ >+ $dbh->do(qq{ >+ INSERT INTO authorised_values (category,authorised_value,lib) VALUES ('LOST',$lost_val,'Missing from bundle') >+ }); >+ >+ my ($nfl_val) = $dbh->selectrow_array( "SELECT MAX(authorised_value) FROM authorised_values WHERE category = 'NOT_LOAN'", {} ); >+ $nfl_val++; >+ >+ $dbh->do(qq{ >+ INSERT INTO authorised_values (category,authorised_value,lib) VALUES ('NOT_LOAN',$nfl_val,'Added to bundle') >+ }); >+ >+ $dbh->do(qq{ >+ INSERT IGNORE INTO systempreferences( `variable`, `value`, `options`, `explanation`, `type` ) >+ VALUES >+ ( 'BundleLostValue', $lost_val, '', 'Sets the LOST AV value that represents "Missing from bundle" as a lost value', 'Free' ), >+ ( 'BundleNotLoanValue', $nfl_val, '', 'Sets the NOT_LOAN AV value that represents "Added to bundle" as a not for loan value', 'Free') >+ }); >+ >+ SetVersion( $DBversion ); >+ print "Upgrade to $DBversion done (Bug 24023 - Items bundles)\n"; >+} >diff --git a/installer/data/mysql/kohastructure.sql b/installer/data/mysql/kohastructure.sql >index 8e5348edff..568c9da5e3 100644 >--- a/installer/data/mysql/kohastructure.sql >+++ b/installer/data/mysql/kohastructure.sql >@@ -3063,6 +3063,23 @@ CREATE TABLE `items` ( > ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; > /*!40101 SET character_set_client = @saved_cs_client */; > >+-- >+-- Table structure for table item_bundles >+-- >+ >+DROP TABLE IF EXISTS `item_bundles`; >+/*!40101 SET @saved_cs_client = @@character_set_client */; >+/*!40101 SET character_set_client = utf8 */; >+CREATE TABLE `item_bundles` ( >+ `item` int(11) NOT NULL, >+ `host` int(11) NOT NULL, >+ PRIMARY KEY (`host`, `item`), >+ UNIQUE KEY `item_bundles_uniq_1` (`item`), >+ CONSTRAINT `item_bundles_ibfk_1` FOREIGN KEY (`item`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE, >+ CONSTRAINT `item_bundles_ibfk_2` FOREIGN KEY (`host`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE >+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; >+/*!40101 SET character_set_client = @saved_cs_client */; >+ > -- > -- Table structure for table `items_last_borrower` > -- >@@ -3083,6 +3100,26 @@ CREATE TABLE `items_last_borrower` ( > ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; > /*!40101 SET character_set_client = @saved_cs_client */; > >+-- >+-- Table structure for table `items_lost_issue` >+-- >+ >+DROP TABLE IF EXISTS `items_lost_issue`; >+/*!40101 SET @saved_cs_client = @@character_set_client */; >+/*!40101 SET character_set_client = utf8 */; >+CREATE TABLE `items_lost_issue` ( >+ `id` int(11) NOT NULL AUTO_INCREMENT, >+ `itemnumber` int(11) NOT NULL, >+ `issue_id` int(11) DEFAULT NULL, >+ `created_on` timestamp NOT NULL DEFAULT current_timestamp() ON UPDATE current_timestamp(), >+ PRIMARY KEY (`id`), >+ UNIQUE KEY `itemnumber` (`itemnumber`), >+ KEY `issue_id` (`issue_id`), >+ CONSTRAINT `items_lost_issue_ibfk_1` FOREIGN KEY (`itemnumber`) REFERENCES `items` (`itemnumber`) ON DELETE CASCADE ON UPDATE CASCADE, >+ CONSTRAINT `items_lost_issue_ibfk_2` FOREIGN KEY (`issue_id`) REFERENCES `old_issues` (`issue_id`) ON DELETE SET NULL ON UPDATE CASCADE >+) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; >+/*!40101 SET character_set_client = @saved_cs_client */; >+ > -- > -- Table structure for table `items_search_fields` > -- >diff --git a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss >index 26e07c9e1a..bed8f8fd48 100644 >--- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss >+++ b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss >@@ -2283,6 +2283,10 @@ td { > display: block; > } > >+.bundled { >+ display: block; >+} >+ > .datedue { > color: #999; > display: block; >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/cataloguing.pref b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/cataloguing.pref >index 5ffd9a22f3..e0029bab61 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/cataloguing.pref >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/cataloguing.pref >@@ -158,6 +158,10 @@ Cataloging: > - and record's last modifier name in MARC subfield > - pref: MarcFieldForModifierName > - ". <br/><strong>NOTE:</strong> Use a dollar sign between field and subfield like 123$a." >+ - >+ - Use the NOT_LOAN authorised value >+ - pref: BundleNotLoanValue >+ - to represent 'added to bundle' when an item is attached to bundle. > Display: > - > - 'Separate main entry and subdivisions with ' >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 fc07ec1ed6..21b4349d0f 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/catalogue/detail.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt >index 7c2b961d6c..75b776242f 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt >@@ -315,6 +315,7 @@ > <table class="items_table" id="[% tab | html %]_table"> > <thead> > <tr> >+ [% IF ( itemdata_bundles ) %]<th id="[% tab | html %]_bundles" data-colname="[% tab | html %]_bundles" class="NoSort"></th>[% END %] > [% IF (StaffDetailItemSelection) %]<th id="[% tab | html %]_checkbox" data-colname="[% tab | html %]_checkbox" class="NoSort"></th>[% END %] > [% IF Koha.Preference('LocalCoverImages') && ( tab == 'holdings' && itemloop_has_images || tab == 'otherholdings' && otheritemloop_has_images ) %] > <th id="[% tab | html %]_cover_image" data-colname="[% tab | html %]_cover_image">Cover image</th> >@@ -347,7 +348,16 @@ > </thead> > <tbody> > [% FOREACH item IN items %] >- <tr> >+ <tr id="item_[% item.itemnumber | html %]" data-itemnumber="[% item.itemnumber | html %]"> >+ [% IF ( itemdata_bundles ) %] >+ [% IF ( item.is_bundle ) %] >+ <td class="details-control"> >+ <button><i class="fa fa-folder-open"></i></button> >+ </td> >+ [% ELSE %] >+ <td></td> >+ [% END %] >+ [% END %] > [% IF (StaffDetailItemSelection) %] > <td style="text-align:center;vertical-align:middle"> > <input type="checkbox" value="[% item.itemnumber | html %]" name="itemnumber" /> >@@ -455,6 +465,9 @@ Note that permanent location is a code, and location may be an authval. > [% ELSE %] > <span class="lost">Unavailable (lost or missing)</span> > [% END %] >+ [% IF ( item.itemlost == Koha.Preference('BundleLostValue') AND item.loss_checkout ) %] >+ <span>[% item.loss_checkout.borrowernumber | html %]</span> >+ [% END %] > [% END %] > > [% IF ( item.withdrawn ) %] >@@ -509,6 +522,10 @@ Note that permanent location is a code, and location may be an authval. > <span class="restricted">([% item.restrictedvalue | html %])</span> > [% END %] > >+ [% IF ( item.bundle_host ) %] >+ <span class="bundled">In bundle: [% INCLUDE 'biblio-title.inc' biblio = item.bundle_host.biblio link = 1 %]</span> >+ [% END %] >+ > </td> > <td class="datelastseen" data-order="[% item.datelastseen | html %]">[% item.datelastseen | $KohaDates %]</td> > <td class="dateaccessioned" data-order="[% item.dateaccessioned | html %]">[% item.dateaccessioned | $KohaDates %]</td> >@@ -593,6 +610,9 @@ Note that permanent location is a code, and location may be an authval. > <a class="btn btn-default btn-xs" href="/cgi-bin/koha/cataloguing/additem.pl?op=edititem&biblionumber=[% item.biblionumber | html %]&itemnumber=[% item.itemnumber | html %]#edititem"><i class="fa fa-pencil"></i> Edit</a> > [% END %] > [% END %] >+ [% IF collection %] >+ <button type="button" data-toggle="modal" data-target="#bundleItemsModal" data-item="[% item.itemnumber | html %]" class="btn btn-default btn-xs"><i class="fa fa-folder"></i> Bundle</button> >+ [% END %] > </td> > [% END %] > </tr> >@@ -1020,6 +1040,35 @@ Note that permanent location is a code, and location may be an authval. > > [% END %] > >+ <div class="modal" id="bundleItemsModal" tabindex="-1" role="dialog" aria-labelledby="bundleItemsLabel"> >+ <form id="bundleItemsForm" action=""> >+ <div class="modal-dialog" role="document"> >+ <div class="modal-content"> >+ <div class="modal-header"> >+ <button type="button" class="closebtn" data-dismiss="modal" aria-hidden="true">Ã</button> >+ <h3 id="bundleItemsLabel">Add to bundle</h3> >+ </div> >+ <div class="modal-body"> >+ <div id="result"></div> >+ <fieldset class="rows"> >+ <ol> >+ <li> >+ <label class="required" for="external_id">Item barcode: </label> >+ <input type="text" id="external_id" name="external_id" required="required"> >+ <span class="required">Required</span> >+ </li> >+ </ol> >+ </fieldset> >+ </div> >+ <div class="modal-footer"> >+ <button type="submit" class="btn btn-default">Submit</button> >+ <button class="btn btn-default" data-dismiss="modal" aria-hidden="true">Close</button> >+ </div> >+ </div> >+ </div> >+ </form> >+ </div> >+ > [% MACRO jsinclude BLOCK %] > [% INCLUDE 'catalog-strings.inc' %] > [% Asset.js("js/catalog.js") | $raw %] >@@ -1315,6 +1364,7 @@ Note that permanent location is a code, and location may be an authval. > [% INCLUDE 'datatables.inc' %] > [% Asset.js("lib/jquery/plugins/jquery.dataTables.columnFilter.js") | $raw %] > [% INCLUDE 'columns_settings.inc' %] >+ [% INCLUDE 'js-date-format.inc' %] > [% Asset.js("js/browser.js") | $raw %] > [% Asset.js("js/table_filters.js") | $raw %] > <script> >@@ -1323,6 +1373,100 @@ Note that permanent location is a code, and location may be an authval. > browser.show(); > > $(document).ready(function() { >+ >+ function createChild ( row, itemnumber ) { >+ // This is the table we'll convert into a DataTable >+ var bundles_table = $('<table class="display" width="100%"/>'); >+ >+ // Display it the child row >+ row.child( bundles_table ).show(); >+ >+ // Initialise as a DataTable >+ var bundle_table_url = "/api/v1/items/" + itemnumber + "/bundled_items?"; >+ var bundle_table = bundles_table.api({ >+ "ajax": { >+ "url": bundle_table_url >+ }, >+ "header_filter": false, >+ "embed": [ >+ "biblio", >+ "checkout" >+ ], >+ "order": [[ 1, "asc" ]], >+ "columns": [ >+ { >+ "data": "biblio.title:biblio.medium", >+ "title": "Title", >+ "searchable": true, >+ "orderable": true, >+ "render": function(data, type, row, meta) { >+ var title = ""; >+ if ( row.biblio.title ) { >+ title = title.concat('<span class="biblio-title">',row.biblio.title,'</span>'); >+ } >+ if ( row.biblio.medium ) { >+ title = title.concat('<span class="biblio-medium">',row.biblio.medium,'</span>'); >+ } >+ return title; >+ } >+ }, >+ { >+ "data": "biblio.author", >+ "title": "Author", >+ "searchable": true, >+ "orderable": true, >+ }, >+ { >+ "data": "ccode", >+ "title": "Collection code", >+ "searchable": true, >+ "orderable": true, >+ }, >+ { >+ "data": "item_type", >+ "title": "Item Type", >+ "searchable": false, >+ "orderable": true, >+ }, >+ { >+ "data": "callnumber", >+ "title": "Callnumber", >+ "searchable": true, >+ "orderable": true, >+ }, >+ { >+ "data": "external_id", >+ "title": "Barcode", >+ "searchable": true, >+ "orderable": true, >+ }, >+ { >+ "data": "lost_status:last_seen_date", >+ "title": "Status", >+ "searchable": false, >+ "orderable": true, >+ "render": function(data, type, row, meta) { >+ if ( row.lost_status ) { >+ return "Lost: " + row.lost_status; >+ } >+ return "Available"; >+ } >+ }, >+ { >+ "data": function( row, type, val, meta ) { >+ var result = '<a class="btn btn-default btn-xs" role="button" href="#removeFromBundleConfirmModal"><i class="fa fa-trash" aria-hidden="true"></i> '+_("Remove")+'</a>\n'; >+ return result; >+ }, >+ "title": "Actions", >+ "searchable": false, >+ "orderable": false >+ } >+ ] >+ }, [], 1); >+ >+ return; >+ } >+ > var ids = ['holdings_table', 'otherholdings_table']; > var columns_settings = [ [% TablesSettings.GetColumns('catalogue', 'detail','holdings_table','json') | $raw %], [% TablesSettings.GetColumns('catalogue', 'detail','otherholdings_table','json') | $raw %] ]; > var has_images = ["[% itemloop_has_images | html %]", "[% otheritemloop_has_images | html %]"]; >@@ -1339,6 +1483,26 @@ Note that permanent location is a code, and location may be an authval. > "sDom": 'C<"top pager"ilpfB><"#filter_c">tr<"bottom pager"ip>', > }; > var table = KohaTable(id, dt_parameters, columns_settings[i], 'with_filters'); >+ >+ // Add event listener for opening and closing details >+ $('#' + id + ' tbody').on('click', 'td.details-control', function () { >+ var tr = $(this).closest('tr'); >+ var dTable = $(this).closest('table').DataTable({ 'retrieve': true }); >+ >+ var itemnumber = tr.data('itemnumber'); >+ var row = dTable.row( tr ); >+ >+ if ( row.child.isShown() ) { >+ // This row is already open - close it >+ row.child.hide(); >+ tr.removeClass('shown'); >+ } >+ else { >+ // Open this row >+ createChild(row, itemnumber); >+ tr.addClass('shown'); >+ } >+ } ); > } > > [% IF Koha.Preference('AcquisitionDetails') %] >@@ -1360,6 +1524,50 @@ Note that permanent location is a code, and location may be an authval. > "sPaginationType": "full" > })); > [% END %] >+ >+ $("#bundleItemsModal").on("shown.bs.modal", function(e){ >+ var button = $(e.relatedTarget); >+ var item_id = button.data('item'); >+ $("#result").replaceWith('<div id="result"></div>'); >+ $("#bundleItemsForm").attr('action', '/api/v1/items/' + item_id + '/bundled_items/item'); >+ $("#external_id").focus(); >+ }); >+ >+ $("#bundleItemsForm").submit(function(event) { >+ >+ /* stop form from submitting normally */ >+ event.preventDefault(); >+ >+ /* get the action attribute from the <form action=""> element */ >+ var $form = $(this), >+ url = $form.attr('action'); >+ >+ /* Send the data using post with external_id */ >+ var posting = $.post(url, JSON.stringify({ >+ external_id: $('#external_id').val() >+ }), null, "json"); >+ >+ /* Report the results */ >+ posting.done(function(data) { >+ var barcode = $('#external_id').val(); >+ $('#result').replaceWith('<div id="result" class="alert alert-success">Success: Added '+barcode+'</div>'); >+ $('#external_id').val('').focus(); >+ }); >+ posting.fail(function(data) { >+ var barcode = $('#external_id').val(); >+ if ( data.status === 409 ) { >+ var response = data.responseJSON; >+ if ( response.key === "PRIMARY" ) { >+ $('#result').replaceWith('<div id="result" class="alert alert-warning">Warning: Item '+barcode+' already attached</div>'); >+ } else { >+ $('#result').replaceWith('<div id="result" class="alert alert-danger">Failure: Item '+barcode+' belongs to another bundle</div>'); >+ } >+ } else { >+ $('#result').replaceWith('<div id="result" class="alert alert-danger">Failure</div>'); >+ } >+ $('#external_id').val('').focus(); >+ }); >+ }); > }); > > $(document).ready(function() { >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 21fda48f6e..9ed2e6d6e1 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,27 @@ > </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 %] > > [% IF ( errmsgloop ) %] > <div class="dialog alert audio-alert-warning"> >@@ -381,6 +403,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 +1069,8 @@ > </div> <!-- /.col-sm-12 --> > </div> <!-- /.row --> > >+ [% INCLUDE 'modals/bundle_contents.inc' %] >+ > [% MACRO jsinclude BLOCK %] > [% INCLUDE 'datatables.inc' %] > [% INCLUDE 'columns_settings.inc' %] >@@ -1190,6 +1275,38 @@ > > }); > </script> >+ >+ <script> >+ $(document).ready(function () { >+ $('#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'); >+ } >+ }) >+ }); >+ >+ $('.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' %] >diff --git a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/inventory.tt b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/inventory.tt >index 8edc3739b4..2a929d76c5 100644 >--- a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/inventory.tt >+++ b/koha-tmpl/intranet-tmpl/prog/en/modules/tools/inventory.tt >@@ -133,6 +133,17 @@ > [% END %] > </select> > </li> >+ [% IF items_bundles.size > 0 %] >+ <li> >+ <label for="items_bundle">Items bundle:</label> >+ <select id="items_bundle" name="items_bundle"> >+ <option value=""></option> >+ [% FOREACH items_bundle IN items_bundles %] >+ <option value="[% items_bundle.items_bundle_id | html %]">[% items_bundle.biblionumber.title | html %]</option> >+ [% END %] >+ </select> >+ </li> >+ [% END %] > </ol> > </fieldset> > >@@ -348,7 +359,8 @@ > $('#locationloop').val() || > $('#minlocation').val() || > $('#maxlocation').val() || >- $('#statuses input:checked').length >+ $('#statuses input:checked').length || >+ $('#items_bundle').val() > ) ) { > return confirm( > _("You have not selected any catalog filters and are about to compare a file of barcodes to your entire catalog.") + "\n\n" + >@@ -488,6 +500,12 @@ > }); > }); > </script> >+ [% INCLUDE 'select2.inc' %] >+ <script> >+ $(document).ready(function () { >+ $('#items_bundle').select2(); >+ }); >+ </script> > [% END %] > > [% INCLUDE 'intranet-bottom.inc' %] >diff --git a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-detail.tt b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-detail.tt >index ce9bff3939..6733e7227a 100644 >--- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-detail.tt >+++ b/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-detail.tt >@@ -1066,7 +1066,9 @@ > <caption class="sr-only">Holdings</caption> > <thead> > <tr> >- >+ [% IF ( itemdata_bundles ) %] >+ <th id="item_bundle" data-colname="item_bundle"></th> >+ [% END %] > [% IF Koha.Preference('OPACLocalCoverImages') && ( tab == 'holdings' && itemloop_has_images || tab == 'otherholdings' && otheritemloop_has_images ) %] > <th id="item_cover" data-colname="item_cover">Cover image</th> > [% END %] >@@ -1116,7 +1118,17 @@ > </thead> > <tbody> > [% FOREACH ITEM_RESULT IN items %] >- <tr vocab="http://schema.org/" typeof="Offer"> >+ <tr vocab="http://schema.org/" typeof="Offer" data-itemnumber="[% ITEM_RESULT.itemnumber | html %]"> >+ >+ [% IF ( itemdata_bundles ) %] >+ [% IF ITEM_RESULT.is_bundle %] >+ <td class="details-control"> >+ <button><i class="fa fa-folder-open"></i></button> >+ </td> >+ [% ELSE %] >+ <td></td> >+ [% END %] >+ [% END %] > > [% IF Koha.Preference('OPACLocalCoverImages') && ( tab == 'holdings' && itemloop_has_images || tab == 'otherholdings' && otheritemloop_has_images ) %] > <td class="cover"> >@@ -1419,6 +1431,82 @@ > "autoWidth": false > }, columns_settings); > >+ function createChild ( row, itemnumber ) { >+ // This is the table we'll convert into a DataTable >+ var bundles_table = $('<table class="display" width="100%"/>'); >+ >+ // Display it the child row >+ row.child( bundles_table ).show(); >+ >+ // Initialise as a DataTable >+ var bundle_table_url = "/api/v1/items/" + itemnumber + "/bundled_items?"; >+ var bundle_table = bundles_table.api({ >+ "ajax": { >+ "url": bundle_table_url >+ }, >+ "header_filter": false, >+ "embed": [ >+ "biblio", >+ "checkout" >+ ], >+ "order": [[ 0, "asc" ]], >+ "columns": [ >+ { >+ "data": "item_type", >+ "title": "Item Type", >+ "searchable": false, >+ "orderable": true, >+ }, >+ { >+ "data": "biblio.title", >+ "title": "Title", >+ "searchable": true, >+ "orderable": true, >+ }, >+ { >+ "data": "damaged_status", >+ "title": "Status", >+ "searchable": false, >+ "orderable": true, >+ }, >+ { >+ "data": "external_id", >+ "title": "Barcode", >+ "searchable": true, >+ "orderable": true, >+ }, >+ { >+ "data": "callnumber", >+ "title": "Callnumber", >+ "searchable": true, >+ "orderable": true, >+ }, >+ ] >+ }, [], 1); >+ >+ return; >+ } >+ >+ // Add event listener for opening and closing details >+ $('#holdingst tbody').on('click', 'td.details-control', function () { >+ var tr = $(this).closest('tr'); >+ var dTable = $(this).closest('table').DataTable({ 'retrieve': true }); >+ >+ var itemnumber = tr.data('itemnumber'); >+ var row = dTable.row( tr ); >+ >+ if ( row.child.isShown() ) { >+ // This row is already open - close it >+ row.child.hide(); >+ tr.removeClass('shown'); >+ } >+ else { >+ // Open this row >+ createChild(row, itemnumber); >+ tr.addClass('shown'); >+ } >+ } ); >+ > var serial_column_settings = [% TablesSettings.GetColumns( 'opac', 'biblio-detail', 'subscriptionst', 'json' ) | $raw %]; > > KohaTable("#subscriptionst", { >diff --git a/opac/opac-detail.pl b/opac/opac-detail.pl >index 55a326fe37..5ab0481115 100755 >--- a/opac/opac-detail.pl >+++ b/opac/opac-detail.pl >@@ -751,6 +751,15 @@ if ( not $viewallitems and @items > $max_items_to_display ) { > $itm->{cover_images} = $item->cover_images; > } > >+ if ($item->is_bundle) { >+ $itemfields{bundles} = 1; >+ $itm->{is_bundle} = 1; >+ } >+ >+ if ($item->in_bundle) { >+ $itm->{bundle_host} = $item->bundle_host; >+ } >+ > my $itembranch = $itm->{$separatebranch}; > if ($currentbranch and C4::Context->preference('OpacSeparateHoldings')) { > if ($itembranch and $itembranch eq $currentbranch) { >@@ -801,6 +810,7 @@ if( C4::Context->preference('ArticleRequests') ) { > MARCNOTES => $marcnotesarray, > norequests => $norequests, > RequestOnOpac => C4::Context->preference("RequestOnOpac"), >+ itemdata_bundles => $itemfields{bundles}, > itemdata_ccode => $itemfields{ccode}, > itemdata_materials => $itemfields{materials}, > itemdata_enumchron => $itemfields{enumchron}, >diff --git a/tools/inventory.pl b/tools/inventory.pl >index 5eac6e4e74..b10c2ddacc 100755 >--- a/tools/inventory.pl >+++ b/tools/inventory.pl >@@ -47,6 +47,7 @@ my $minlocation=$input->param('minlocation') || ''; > my $maxlocation=$input->param('maxlocation'); > my $class_source=$input->param('class_source'); > $maxlocation=$minlocation.'Z' unless ( $maxlocation || ! $minlocation ); >+my $items_bundle = $input->param('items_bundle'); > my $location=$input->param('location') || ''; > my $ignoreissued=$input->param('ignoreissued'); > my $ignore_waiting_holds = $input->param('ignore_waiting_holds'); >@@ -66,6 +67,12 @@ my ( $template, $borrowernumber, $cookie ) = get_template_and_user( > } > ); > >+my $schema = Koha::Database->new()->schema(); >+my $items_bundle_rs = $schema->resultset('ItemsBundle'); >+my @items_bundles = $items_bundle_rs->search(undef, { >+ order_by => { -asc => ['biblionumber.title'] }, >+ join => ['biblionumber'], >+}); > my @authorised_value_list; > my $authorisedvalue_categories = ''; > >@@ -134,6 +141,7 @@ foreach my $itemtype ( @itemtypes ) { > > $template->param( > authorised_values => \@authorised_value_list, >+ items_bundles => \@items_bundles, > today => dt_from_string, > minlocation => $minlocation, > maxlocation => $maxlocation, >@@ -249,6 +257,7 @@ if ( $op && ( !$uploadbarcodes || $compareinv2barcd )) { > minlocation => $minlocation, > maxlocation => $maxlocation, > class_source => $class_source, >+ items_bundle => $items_bundle, > location => $location, > ignoreissued => $ignoreissued, > datelastseen => $datelastseen, >@@ -267,6 +276,7 @@ if( @scanned_items ) { > maxlocation => $maxlocation, > class_source => $class_source, > location => $location, >+ items_bundle => $items_bundle, > ignoreissued => undef, > datelastseen => undef, > branchcode => $branchcode, >-- >2.20.1
You cannot view the attachment while viewing its details because your browser does not support IFRAMEs.
View the attachment on a separate page
.
View Attachment As Diff
View Attachment As Raw
Actions:
View
|
Diff
|
Splinter Review
Attachments on
bug 28854
:
124319
|
124498
|
124616
|
125200
|
125201
|
125202
|
125203
|
125204
|
125205
|
125206
|
126103
|
126104
|
126105
|
126106
|
126107
|
126108
|
126109
|
126110
|
126148
|
126149
|
126150
|
126151
|
126152
|
126153
|
126154
|
126155
|
126166
|
126167
|
126168
|
126169
|
126170
|
126171
|
126172
|
126173
|
127498
|
127499
|
127500
|
127501
|
127502
|
127503
|
127504
|
127505
|
127659
|
127660
|
127661
|
127662
|
127663
|
127664
|
127665
|
127666
|
127829
|
127830
|
127831
|
127832
|
127833
|
127834
|
127835
|
127836
|
127837
|
127838
|
130313
|
130314
|
130315
|
130316
|
130317
|
130318
|
130319
|
130320
|
130321
|
130322
|
130323
|
130324
|
130325
|
130326
|
130327
|
130328
|
130329
|
130330
|
130331
|
130332
|
130456
|
132703
|
132704
|
132705
|
132706
|
132707
|
132708
|
132709
|
132710
|
132711
|
132712
|
132713
|
132714
|
132715
|
132716
|
132717
|
132718
|
132719
|
132720
|
132721
|
132722
|
132723
|
135019
|
135020
|
135021
|
135022
|
135023
|
135024
|
135025
|
135026
|
135027
|
135028
|
135029
|
135030
|
135031
|
135032
|
135033
|
135034
|
135035
|
135036
|
135037
|
136226
|
136227
|
136228
|
136229
|
136230
|
136231
|
136232
|
136233
|
136234
|
136235
|
136236
|
136237
|
136238
|
136239
|
136240
|
136241
|
136242
|
136243
|
136244
|
136249
|
136253
|
136254
|
136264
|
136265
|
136266
|
136267
|
136268
|
136269
|
136270
|
136271
|
136272
|
136273
|
136274
|
136275
|
136276
|
136277
|
136278
|
136279
|
136280
|
136281
|
136282
|
136283
|
136284
|
136285
|
136287
|
136340
|
136341
|
136342
|
136343
|
136344
|
136345
|
136346
|
136347
|
136348
|
136349
|
136350
|
136351
|
136352
|
136353
|
136354
|
136355
|
136356
|
136357
|
136358
|
136359
|
136360
|
136361
|
136362
|
136363
|
136364
|
136365
|
136366
|
136367
|
136368
|
136369
|
136370
|
136371
|
136498
|
136529
|
136530
|
136613
|
136614
|
136615
|
136616
|
136617
|
136618
|
136619
|
136620
|
136621
|
136622
|
136623
|
136624
|
136625
|
136626
|
136627
|
136628
|
136629
|
136630
|
136631
|
136632
|
136633
|
136634
|
136635
|
136636
|
136637
|
136638
|
136639
|
136640
|
136641
|
136642
|
136643
|
136644
|
136645
|
136646
|
136647
|
136648
|
136649
|
136650
|
136651
|
136652
|
136654
|
136655
|
136806
|
136809
|
136811
|
136814
|
136817
|
136821
|
136824
|
136826
|
136829
|
136832
|
136835
|
136838
|
136841
|
136843
|
136846
|
136849
|
136850
|
136851
|
136852
|
136853
|
136854
|
136855
|
136856
|
136857
|
136858
|
136859
|
136860
|
136861
|
136862
|
136863
|
136864
|
136865
|
136866
|
136867
|
136868
|
136869
|
136870
|
136871
|
136872
|
136873
|
136874
|
136875
|
136876
|
136877
|
136905
|
136906
|
136907
|
136908
|
136909
|
136910
|
136911
|
136912
|
136913
|
136914
|
136915
|
136916
|
136917
|
136918
|
136919
|
136920
|
136921
|
136922
|
136923
|
136924
|
136925
|
136926
|
136927
|
136928
|
136929
|
136930
|
136931
|
136932
|
136933
|
136934
|
136935
|
136936
|
136937
|
136938
|
136939
|
136940
|
136941
|
136942
|
136943
|
136944
|
136945
|
136946
|
136947
|
136949
|
136978
|
136979
|
136980
|
136981
|
136982
|
136985
|
136988
|
136989
|
136990
|
136991
|
136992
|
136993
|
136994
|
136995
|
136996
|
136997
|
136998
|
136999
|
137000
|
137001
|
137002
|
137003
|
137004
|
137005
|
137006
|
137007
|
137008
|
137009
|
137010
|
137011
|
137012
|
137013
|
137014
|
137015
|
137016
|
137017
|
137018
|
137019
|
137020
|
137021
|
137022
|
137461
|
137462
|
137463
|
137464
|
137465
|
137466
|
137467
|
137468
|
137469
|
137470
|
137471
|
137472
|
137473
|
137474
|
137475
|
137476
|
137477
|
137478
|
137479
|
137480
|
137481
|
137482
|
137483
|
137484
|
137485
|
137486
|
137487
|
137488
|
137489
|
137490
|
137491
|
137492
|
137493
|
137494
|
137495
|
137496
|
137497
|
137498
|
137499
|
137500
|
137501
|
137503
|
137506
|
137524
|
137525
|
137527
|
137528
|
137529
|
137530
|
137531
|
137532
|
137533
|
137534
|
137535
|
137536
|
137537
|
137538
|
137540
|
137541
|
137542
|
137543
|
137544
|
137545
|
137546
|
137547
|
137548
|
137549
|
137550
|
137551
|
137552
|
137553
|
137554
|
137555
|
137556
|
137557
|
137558
|
137559
|
137560
|
137561
|
137562
|
137563
|
137564
|
137565
|
137566
|
137567
|
137568
|
137569
|
137570
|
137571
|
137684
|
137685
|
139347