From 1cc9d853ebb4f4c9e07a853f47eadfc19ecea72c Mon Sep 17 00:00:00 2001 From: Martin Renvoize 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 | 198 +++++++++++++++ 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 | 9 + 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 | 226 ++++++++++++++++- .../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, 1679 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 7f83736ace..fe5daee262 100644 --- a/C4/Items.pm +++ b/C4/Items.pm @@ -525,6 +525,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'} // ''; @@ -571,6 +572,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 60d80a5c3e..9a6483c3eb 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 ); @@ -394,6 +395,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(); @@ -1272,6 +1306,170 @@ sub move_to_biblio { return $to_biblionumber; } +=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 \(`(?.*?)`\)/ ) { + Koha::Exceptions::Object::FKConstraint->throw( + error => 'Broken FK constraint', + broken_fk => $+{column} + ); + } + } + elsif ( + $_->{msg} =~ /Duplicate entry '(.*?)' for key '(?.*?)'/ ) + { + Koha::Exceptions::Object::DuplicateID->throw( + error => 'Duplicate ID', + duplicate_id => $+{key} + ); + } + elsif ( $_->{msg} =~ +/Incorrect (?\w+) value: '(?.*)' for column \W?(?\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 . + +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 . + +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..c95971fda6 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('bundled_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 a53e10e98a..32c0dfad13 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 + +=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 + +=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 @@ -865,8 +895,8 @@ __PACKAGE__->has_many( ); -# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-08-27 08:42:21 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:SjZn3haOtUZWu1jrMigjNQ +# 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 + +=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 + +=item * L + +=back + +=cut + +__PACKAGE__->set_primary_key("host", "item"); + +=head1 UNIQUE CONSTRAINTS + +=head2 C + +=over 4 + +=item * L + +=back + +=cut + +__PACKAGE__->add_unique_constraint("item_bundles_uniq_1", ["item"]); + +=head1 RELATIONS + +=head2 host + +Type: belongs_to + +Related object: L + +=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 + +=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..31e58d13d7 100644 --- a/api/v1/swagger/paths.json +++ b/api/v1/swagger/paths.json @@ -83,6 +83,15 @@ "/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}/bundled_items/item/{bundled_item_id}": { + "$ref": "paths/items.json#/~1items~1{item_id}~1bundled_items~1item~1{bundled_item_id}" + }, "/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..a0a2caf9d7 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 + } + } + } + }, + "/items/{item_id}/bundled_items/item/{bundled_item_id}": { + "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": "bundled_item_id", + "in": "path", + "description": "Internal identifier for the bundled item", + "required": true, + "type": "string" + } + ], + "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..90d71a7be2 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 02cf864ddd..f566debcb2 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 9d8f5e8069..8ee0df0a57 100644 --- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss +++ b/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss @@ -2293,6 +2293,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 %] + + [% 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 %] + +[% ELSIF transfer %] + [% datesent = BLOCK %][% transfer.datesent | $KohaDates %][% END %] + [% tx('In transit from {frombranch} to {tobranch} since {datesent}', { frombranch = Branches.GetName(transfer.frombranch), tobranch = Branches.GetName(transfer.tobranch), datesent = datesent }) %] +[% END %] + +[% IF item.itemlost %] + [% itemlost_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.itemlost', authorised_value = item.itemlost }) %] + [% IF itemlost_description %] + [% itemlost_description | html %] + [% ELSE %] + [% t('Unavailable (lost or missing)') | html %] + [% END %] +[% END %] + +[% IF item.withdrawn %] + [% withdrawn_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.withdrawn', authorised_value = item.withdrawn }) %] + [% IF withdrawn_description %] + [% withdrawn_description | html %] + [% ELSE %] + [% t('Withdrawn') | html %] + [% END %] +[% END %] + +[% IF item.damaged %] + [% damaged_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.damaged', authorised_value = item.damaged }) %] + [% IF damaged_description %] + [% damaged_description | html %] + [% ELSE %] + [% t('Damaged') | html %] + [% END %] +[% END %] + +[% IF item.notforloan || item.effective_itemtype.notforloan %] + + [% t('Not for loan') | html %] + [% notforloan_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.notforloan', authorised_value = item.notforloan }) %] + [% IF notforloan_description %] + ([% notforloan_description | html %]) + [% END %] + +[% END %] + +[% hold = item.holds.next %] +[% IF hold %] + + [% 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 %] + +[% END %] +[% UNLESS item.notforloan || item.effective_itemtype.notforloan || item.onloan || item.itemlost || item.withdrawn || item.damaged || transfer || hold %] + [% t('Available') | html %] +[% END %] + +[% IF ( item.restricted ) %] + [% restricted_description = AuthorisedValues.GetDescriptionByKohaField({ kohafield = 'items.restricted', authorised_value = item.restricted }) %] + [% IF restricted_description %] + ([% restricted_description | html %]) + [% ELSE %] + [% t('Restricted') | html %] + [% 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 @@ + + 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 - ".
NOTE: 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..82e8cfac1f 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 @@ + [% IF ( itemdata_bundles ) %][% END %] [% IF (StaffDetailItemSelection) %][% END %] [% IF Koha.Preference('LocalCoverImages') && ( tab == 'holdings' && itemloop_has_images || tab == 'otherholdings' && otheritemloop_has_images ) %] @@ -347,7 +348,16 @@ [% FOREACH item IN items %] - + + [% IF ( itemdata_bundles ) %] + [% IF ( item.is_bundle ) %] + + [% ELSE %] + + [% END %] + [% END %] [% IF (StaffDetailItemSelection) %] @@ -593,6 +610,9 @@ Note that permanent location is a code, and location may be an authval. Edit [% END %] [% END %] + [% IF collection %] + + [% END %] [% END %] @@ -1020,6 +1040,35 @@ Note that permanent location is a code, and location may be an authval. [% END %] + + [% 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 %] + + [% 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 %] + [% IF items_bundles.size > 0 %] +
  • + + +
  • + [% END %] @@ -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 @@ }); }); + [% INCLUDE 'select2.inc' %] + [% 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 @@ - + [% IF ( itemdata_bundles ) %] + + [% END %] [% IF Koha.Preference('OPACLocalCoverImages') && ( tab == 'holdings' && itemloop_has_images || tab == 'otherholdings' && otheritemloop_has_images ) %] [% END %] @@ -1116,7 +1118,17 @@ [% FOREACH ITEM_RESULT IN items %] - + + + [% IF ( itemdata_bundles ) %] + [% IF ITEM_RESULT.is_bundle %] + + [% ELSE %] + + [% END %] + [% END %] [% IF Koha.Preference('OPACLocalCoverImages') && ( tab == 'holdings' && itemloop_has_images || tab == 'otherholdings' && otheritemloop_has_images ) %]
    Cover image
    + + @@ -455,6 +465,9 @@ Note that permanent location is a code, and location may be an authval. [% ELSE %] Unavailable (lost or missing) [% END %] + [% IF ( item.itemlost == Koha.Preference('BundleLostValue') AND item.loss_checkout ) %] + [% item.loss_checkout.borrowernumber | html %] + [% END %] [% END %] [% IF ( item.withdrawn ) %] @@ -509,6 +522,10 @@ Note that permanent location is a code, and location may be an authval. ([% item.restrictedvalue | html %]) [% END %] + [% IF ( item.bundle_host ) %] + In bundle: [% INCLUDE 'biblio-title.inc' biblio = item.bundle_host.biblio link = 1 %] + [% END %] + [% item.datelastseen | $KohaDates %] [% item.dateaccessioned | $KohaDates %]
    Holdings
    Cover image
    + + @@ -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 = $(''); + + // 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 a9eb913f00..9af79f2176 100755 --- a/opac/opac-detail.pl +++ b/opac/opac-detail.pl @@ -752,6 +752,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) { @@ -802,6 +811,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