@@ -, +, @@ table. Test: Verify that this 'Bundle' button only appears for biblio's where leader position 7 is not 'c' for collection 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. 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. Test: Checkout should proceed as normal, obeying the circulation rules for the item type you chose to convert into a bundle. 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 --- a/C4/Accounts.pm +++ a/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 }); --- a/C4/Items.pm +++ a/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)'; --- a/Koha/Biblio.pm +++ a/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(); --- a/Koha/Item.pm +++ a/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 --- a/Koha/Item/Bundle.pm +++ a/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; --- a/Koha/Item/Bundles.pm +++ a/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; --- a/Koha/REST/V1/Items.pm +++ a/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; --- a/Koha/Schema/Result/Item.pm +++ a/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" ); --- a/Koha/Schema/Result/ItemBundle.pm +++ a/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; --- a/api/v1/swagger/definitions.json +++ a/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" }, --- a/api/v1/swagger/definitions/bundle_link.json +++ a/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 +} --- a/api/v1/swagger/definitions/item.json +++ a/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, --- a/api/v1/swagger/paths.json +++ a/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" }, --- a/api/v1/swagger/paths/items.json +++ a/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", --- a/catalogue/detail.pl +++ a/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}, --- a/circ/returns.pl +++ a/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 ); --- a/installer/data/mysql/atomicupdate/bug-24023-items-bundles.perl +++ a/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"; +} --- a/installer/data/mysql/kohastructure.sql +++ a/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` -- --- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss +++ a/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; --- a/koha-tmpl/intranet-tmpl/prog/en/includes/item-status.inc +++ a/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 %] --- a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/modals/bundle_contents.inc @@ -0,0 +1,35 @@ + + --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/cataloguing.pref +++ a/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 ' --- a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref +++ a/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: --- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt +++ a/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' %] --- a/koha-tmpl/intranet-tmpl/prog/en/modules/tools/inventory.tt +++ a/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' %] --- a/koha-tmpl/opac-tmpl/bootstrap/en/modules/opac-detail.tt +++ a/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", { --- a/opac/opac-detail.pl +++ a/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}, --- a/tools/inventory.pl +++ a/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, --