@@ -, +, @@ database update and point to a newly added AV value. * Add a new bib record * Mark the bib record as a 'collection' type by setting leader position 7 to 'c' * Add a new item to this bib record * You should see a new 'Manage bundle' button available in the 'Actions' column of the Holdings table. * Clicking 'Manage bundle' should expand the table to include a new row directly beneath this one. * Use the new 'Add to bundle' button that appears in this row to trigger a modal that allows entering the barcode of items you wish to add to the bundle * Upon closing the modal, the bundle content table should reload and contain your newly associated items. * You can subsequently remove an item from a bundle using the new 'Remove' button. * Items that have been added into a bundle should now appear as 'Not for loan' from their original biblio record and note which bundle they belong to. * Try adding an item that already belongs to a bundle to another bundle: Note an error is displayed in the modal form. `BundleNotLoanValue` system preference. * An alert should be triggered noting that the item belongs to a bundle * The option to remove the item from the bundle should be clear * Click remove should result in the alert dissapearing and the item having been removed from the bundle. * A modal confirmation dialog should appear requesting each item barcode be scanned * As items are scanned they should be highlighted in yellow in the bundle content table * Upon submission; * The user will be alerted to any unexpected items that were scanned and told to put them to one side. * The user will be alerted that any missing items in the validation will have been marked as lost. * The bundle item will be marked as checked in. confirmation * Note that the item not scanned is marked as lost as lost. item that contains the lost item. Note the item is marked as lost and checkout details are listed. * The item should be marked as found and the return_claims line should be marked as resolved. --- C4/Accounts.pm | 7 +- C4/Circulation.pm | 6 + Koha/Checkouts/ReturnClaims.pm | 2 +- Koha/Item.pm | 191 ++++++++- Koha/REST/V1/Items.pm | 124 ++++++ Koha/Schema/Result/Item.pm | 30 ++ Koha/Schema/Result/ItemBundle.pm | 113 ++++++ Koha/Schema/Result/ReturnClaim.pm | 18 +- admin/columns_settings.yml | 18 + api/v1/swagger/definitions/bundle_link.yaml | 14 + api/v1/swagger/definitions/item.yaml | 9 + api/v1/swagger/paths/items.yaml | 160 ++++++++ api/v1/swagger/swagger.yaml | 6 + catalogue/detail.pl | 18 + circ/returns.pl | 98 ++++- .../data/mysql/atomicupdate/bug_28854.pl | 55 +++ installer/data/mysql/en/optional/auth_val.yml | 8 + installer/data/mysql/kohastructure.sql | 18 +- installer/data/mysql/mandatory/sysprefs.sql | 2 + .../intranet-tmpl/prog/css/src/_tables.scss | 2 +- .../prog/css/src/staff-global.scss | 14 + .../prog/en/includes/html_helpers.inc | 2 + .../prog/en/includes/js-biblio-format.inc | 64 +++ .../en/includes/modals/bundle_contents.inc | 35 ++ .../admin/preferences/circulation.pref | 11 + .../prog/en/modules/catalogue/detail.tt | 370 +++++++++++++++++- .../prog/en/modules/circ/circulation.tt | 8 +- .../prog/en/modules/circ/returns.tt | 242 ++++++++++++ .../prog/en/modules/members/moremember.tt | 2 +- .../bootstrap/en/includes/item-status.inc | 4 + opac/opac-detail.pl | 5 + t/db_dependent/Accounts.t | 16 +- t/db_dependent/Circulation.t | 19 + t/db_dependent/Koha/Item.t | 195 ++++++++- 34 files changed, 1854 insertions(+), 32 deletions(-) create mode 100644 Koha/Schema/Result/ItemBundle.pm create mode 100644 api/v1/swagger/definitions/bundle_link.yaml create mode 100755 installer/data/mysql/atomicupdate/bug_28854.pl create mode 100644 koha-tmpl/intranet-tmpl/prog/en/includes/js-biblio-format.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 = $item->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/Circulation.pm +++ a/C4/Circulation.pm @@ -2433,6 +2433,12 @@ sub AddReturn { } } + # Check for bundle status + if ( $item->in_bundle ) { + my $host = $item->bundle_host; + $messages->{InBundle} = $host; + } + my $indexer = Koha::SearchEngine::Indexer->new({ index => $Koha::SearchEngine::BIBLIOS_INDEX }); $indexer->index_records( $item->biblionumber, "specialUpdate", "biblioserver" ); --- a/Koha/Checkouts/ReturnClaims.pm +++ a/Koha/Checkouts/ReturnClaims.pm @@ -60,7 +60,7 @@ sub resolved { return Koha::Checkouts::ReturnClaims->_new_from_dbic( $results ); } -=head3 type +=head3 _type =cut --- a/Koha/Item.pm +++ a/Koha/Item.pm @@ -20,6 +20,7 @@ package Koha::Item; use Modern::Perl; use List::MoreUtils qw( any ); +use Try::Tiny qw( catch try ); use Koha::Database; use Koha::DateUtils qw( dt_from_string output_pref ); @@ -440,6 +441,37 @@ sub item_group { return $item_group; } +=head3 return_claims + + my $return_claims = $item->return_claims; + +Return any return_claims associated with this item + +=cut + +sub return_claims { + my ( $self, $params, $attrs ) = @_; + my $claims_rs = $self->_result->return_claims->search($params, $attrs); + return Koha::Checkouts::ReturnClaims->_new_from_dbic( $claims_rs ); +} + +=head3 return_claim + + my $return_claim = $item->return_claim; + +Returns the most recent unresolved return_claims associated with this item + +=cut + +sub return_claim { + my ($self) = @_; + my $claims_rs = + $self->_result->return_claims->search( { resolution => undef }, + { order_by => { '-desc' => 'created_on' }, rows => 1 } )->single; + return unless $claims_rs; + return Koha::Checkouts::ReturnClaim->_new_from_dbic($claims_rs); +} + =head3 holds my $holds = $item->holds(); @@ -1113,7 +1145,7 @@ Internal function, not exported, called only by Koha::Item->store. sub _set_found_trigger { my ( $self, $pre_mod_item ) = @_; - ## If item was lost, it has now been found, reverse any list item charges if necessary. + # Reverse any lost item charges if necessary. my $no_refund_after_days = C4::Context->preference('NoRefundOnLostReturnedItemsAge'); if ($no_refund_after_days) { @@ -1465,6 +1497,163 @@ 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) = @_; + + my $bundle_items_rs = $self->_result->item_bundles_item; + return unless $bundle_items_rs; + return Koha::Item->_new_from_dbic($bundle_items_rs->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 from 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/REST/V1/Items.pm +++ a/Koha/REST/V1/Items.pm @@ -19,6 +19,8 @@ use Modern::Perl; use Mojo::Base 'Mojolicious::Controller'; +use C4::Circulation qw( barcodedecode ); + use Koha::Items; use List::MoreUtils qw( any ); @@ -148,4 +150,126 @@ 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'}; + $bundle_item_id = barcodedecode($bundle_item_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'); + $bundle_item_id = barcodedecode($bundle_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, + openapi => q{} + ); +} + 1; --- a/Koha/Schema/Result/Item.pm +++ a/Koha/Schema/Result/Item.pm @@ -744,6 +744,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 --- 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 @ 2022-03-31 13:56:51 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:T02Qk/Ojl7OXlL62uQk6Og + + +# You can replace this text with custom code or comments, and it will be preserved on regeneration +1; --- a/Koha/Schema/Result/ReturnClaim.pm +++ a/Koha/Schema/Result/ReturnClaim.pm @@ -170,20 +170,6 @@ __PACKAGE__->add_columns( __PACKAGE__->set_primary_key("id"); -=head1 UNIQUE CONSTRAINTS - -=head2 C - -=over 4 - -=item * L - -=back - -=cut - -__PACKAGE__->add_unique_constraint("issue_id", ["issue_id"]); - =head1 RELATIONS =head2 borrowernumber @@ -277,8 +263,8 @@ __PACKAGE__->belongs_to( ); -# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-11-17 10:01:24 -# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:Ik93SD3kLNecIyRgsBVKDQ +# Created by DBIx::Class::Schema::Loader v0.07049 @ 2021-11-18 15:07:03 +# DO NOT MODIFY THIS OR ANYTHING ABOVE! md5sum:HtOvm4y611GNQcPwKzY1jg =head2 checkout --- a/admin/columns_settings.yml +++ a/admin/columns_settings.yml @@ -525,6 +525,24 @@ modules: - columnname: checkin_on + bundle_tables: + columns: + - + columnname: title + cannot_be_toggled: 1 + - + columnname: author + - + columnname: callnumber + - + columnname: barcode + - + columnname: status + - + columnname: bundle_actions + cannot_be_toggled: 1 + cannot_be_modified: 1 + cataloguing: addbooks: reservoir-table: --- a/api/v1/swagger/definitions/bundle_link.yaml +++ a/api/v1/swagger/definitions/bundle_link.yaml @@ -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.yaml +++ a/api/v1/swagger/definitions/item.yaml @@ -7,6 +7,7 @@ properties: biblio_id: type: integer description: Internal identifier for the parent bibliographic record + biblio: {} external_id: type: - string @@ -211,6 +212,14 @@ properties: exclude_from_local_holds_priority: type: boolean description: Exclude this item from local holds priority. + return_claims: + type: array + description: An array of all return claims associated with this item + return_claim: + type: + - object + - "null" + description: A return claims object if one exists that's unresolved additionalProperties: false required: - item_id --- a/api/v1/swagger/paths/items.yaml +++ a/api/v1/swagger/paths/items.yaml @@ -93,6 +93,166 @@ x-koha-authorization: permissions: catalogue: "1" +"/items/{item_id}/bundled_items": + post: + x-mojo-to: Items#add_to_bundle + operationId: addToBundle + tags: + - items + summary: Add item to bundle + parameters: + - $ref: "../swagger.yaml#/parameters/item_id_pp" + - name: body + in: body + description: A JSON object containing information about the new bundle link + required: true + schema: + $ref: "../swagger.yaml#/definitions/bundle_link" + consumes: + - application/json + produces: + - application/json + responses: + "201": + description: A successfully created bundle link + schema: + items: + $ref: "../swagger.yaml#/definitions/item" + "400": + description: Bad parameter + schema: + $ref: "../swagger.yaml#/definitions/error" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: Resource not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "409": + description: Conflict in creating resource + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: Internal server error + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + catalogue: 1 + get: + x-mojo-to: Items#bundled_items + operationId: bundledItems + tags: + - items + summary: List bundled items + parameters: + - $ref: "../swagger.yaml#/parameters/item_id_pp" + - name: external_id + in: query + description: Search on the item's barcode + required: false + type: string + - $ref: "../swagger.yaml#/parameters/match" + - $ref: "../swagger.yaml#/parameters/order_by" + - $ref: "../swagger.yaml#/parameters/page" + - $ref: "../swagger.yaml#/parameters/per_page" + - $ref: "../swagger.yaml#/parameters/q_param" + - $ref: "../swagger.yaml#/parameters/q_body" + - $ref: "../swagger.yaml#/parameters/q_header" + consumes: + - application/json + produces: + - application/json + responses: + "200": + description: A list of item + schema: + type: array + items: + $ref: "../swagger.yaml#/definitions/item" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: Internal server error + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + catalogue: "1" + x-koha-embed: + - biblio + - checkout + - return_claims + - return_claim + - return_claim.patron +"/items/{item_id}/bundled_items/{bundled_item_id}": + delete: + x-mojo-to: Items#remove_from_bundle + operationId: removeFromBundle + tags: + - items + summary: Remove item from bundle + parameters: + - $ref: "../swagger.yaml#/parameters/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: "../swagger.yaml#/definitions/error" + "401": + description: Authentication required + schema: + $ref: "../swagger.yaml#/definitions/error" + "403": + description: Access forbidden + schema: + $ref: "../swagger.yaml#/definitions/error" + "404": + description: Resource not found + schema: + $ref: "../swagger.yaml#/definitions/error" + "500": + description: Internal server error + schema: + $ref: "../swagger.yaml#/definitions/error" + "503": + description: Under maintenance + schema: + $ref: "../swagger.yaml#/definitions/error" + x-koha-authorization: + permissions: + catalogue: 1 "/items/{item_id}/pickup_locations": get: x-mojo-to: Items#pickup_locations --- a/api/v1/swagger/swagger.yaml +++ a/api/v1/swagger/swagger.yaml @@ -10,6 +10,8 @@ definitions: $ref: ./definitions/allows_renewal.yaml basket: $ref: ./definitions/basket.yaml + bundle_link: + $ref: ./definitions/bundle_link.yaml cashup: $ref: ./definitions/cashup.yaml checkout: @@ -169,6 +171,10 @@ paths: $ref: ./paths/items.yaml#/~1items "/items/{item_id}": $ref: "./paths/items.yaml#/~1items~1{item_id}" + "/items/{item_id}/bundled_items": + $ref: ./paths/items.yaml#/~1items~1{item_id}~1bundled_items + "/items/{item_id}/bundled_items/{bundled_item_id}": + $ref: ./paths/items.yaml#/~1items~1{item_id}~1bundled_items~1{bundled_item_id} "/items/{item_id}/pickup_locations": $ref: "./paths/items.yaml#/~1items~1{item_id}~1pickup_locations" /libraries: --- a/catalogue/detail.pl +++ a/catalogue/detail.pl @@ -206,6 +206,11 @@ if (@hostitems){ my $dat = &GetBiblioData($biblionumber); +#is biblio a collection and are bundles enabled +my $leader = $record->leader(); +$dat->{bundlesEnabled} = ( ( substr( $leader, 7, 1 ) eq 'c' ) + && C4::Context->preference('BundleNotLoanValue') ) ? 1 : 0; + #coping with subscriptions my $subscriptionsnumber = CountSubscriptionFromBiblionumber($biblionumber); my @subscriptions = SearchSubscriptions({ biblionumber => $biblionumber, orderby => 'title' }); @@ -451,6 +456,19 @@ foreach my $item (@items) { } } + if ( $item_object->is_bundle ) { + $item->{bundled} = + $item_object->bundle_items->search( { itemlost => { '!=' => 0 } } ) + ->count; + $item->{bundled_lost} = + $item_object->bundle_items->search( { itemlost => 0 } )->count; + $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; --- a/circ/returns.pl +++ a/circ/returns.pl @@ -338,10 +338,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'); @@ -382,7 +390,8 @@ if ($barcode) { ); } } - } elsif ( C4::Context->preference('ShowAllCheckins') and !$messages->{'BadBarcode'} and !$needs_confirm ) { + + } elsif ( C4::Context->preference('ShowAllCheckins') and !$messages->{'BadBarcode'} and !$needs_confirm and !$bundle_confirm ) { $input{duedate} = 0; $returneditems{0} = $barcode; $riduedate{0} = 0; @@ -393,6 +402,89 @@ if ($barcode) { if ( $needs_confirm ) { $template->param( needs_confirm => $needs_confirm ); } + + if ( $bundle_confirm ) { + $template->param( + items_bundle_return_confirmation => 1, + ); + } + + # Mark missing bundle items as lost and report unexpected items + if ( $item->is_bundle && $query->param('confirm_items_bundle_return') ) { + my $BundleLostValue = C4::Context->preference('BundleLostValue'); + 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); + + # Update last_seen + $verify_item->datelastseen( dt_from_string()->ymd() ); + + # Update last_borrowed if actual checkin + $verify_item->datelastborrowed( dt_from_string()->ymd() ) if $issue; + + # 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}; + # Mark as lost if it's not already lost + if ( !$bundle_item->itemlost ) { + $bundle_item->itemlost($BundleLostValue)->store(); + + # Add return_claim record if this is an actual checkin + if ($issue) { + $bundle_item->_result->create_related( + 'return_claims', + { + issue_id => $issue->issue_id, + itemnumber => $bundle_item->itemnumber, + borrowernumber => $issue->borrowernumber, + created_by => C4::Context->userenv()->{number}, + created_on => dt_from_string + } + ); + } + push @missing_items, $bundle_item; + + # NOTE: We cannot use C4::LostItem here because the item itself doesn't have a checkout + # and thus would not get charged.. it's checked out as part of the bundle. + if ( C4::Context->preference('WhenLostChargeReplacementFee') && $issue ) { + C4::Accounts::chargelostitem( + $issue->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 + ); + } } $template->param( inputloop => \@inputloop ); @@ -631,6 +723,8 @@ foreach my $code ( keys %$messages ) { ; } elsif ( $code eq 'TransferredRecall' ) { ; + } elsif ( $code eq 'InBundle' ) { + $template->param( InBundle => $messages->{InBundle} ); } else { die "Unknown error code $code"; # note we need all the (empty) elsif's above, or we die. # This forces the issue of staying in sync w/ Circulation.pm --- a/installer/data/mysql/atomicupdate/bug_28854.pl +++ a/installer/data/mysql/atomicupdate/bug_28854.pl @@ -0,0 +1,55 @@ +use Modern::Perl; + +return { + bug_number => "28854", + description => "Item bundles support", + up => sub { + my ($args) = @_; + my ($dbh, $out) = @$args{qw(dbh out)}; + + 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 + }); + } + say $out "item_bundles table added"; + + 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') + }); + say $out "Missing from bundle LOST AV added"; + + 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') + }); + say $out "Added to bundle NOT_LOAN AV added"; + + $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') + }); + say $out "System preferences added and set"; + + if( index_exists( 'return_claims', 'issue_id' ) ) { + $dbh->do(q{ + ALTER TABLE return_claims DROP INDEX issue_id + }); + say $out "Dropped unique constraint on issue_id in return_claims"; + } + } +} --- a/installer/data/mysql/en/optional/auth_val.yml +++ a/installer/data/mysql/en/optional/auth_val.yml @@ -106,6 +106,10 @@ tables: authorised_value: "4" lib : "Missing" + - category: "LOST" + authorised_value: "5" + lib : "Missing from bundle" + # damaged status of an item - category: "DAMAGED" authorised_value: "1" @@ -183,6 +187,10 @@ tables: authorised_value: "2" lib: "Staff Collection" + - category: "NOT_LOAN" + authorised_value: "3" + lib: "Added to bundle" + # restricted status of an item,linked to items.restricted - category: "RESTRICTED" authorised_value: "1" --- a/installer/data/mysql/kohastructure.sql +++ a/installer/data/mysql/kohastructure.sql @@ -3173,6 +3173,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` -- @@ -4497,7 +4514,6 @@ CREATE TABLE `return_claims` ( `resolved_on` timestamp NULL DEFAULT NULL COMMENT 'Time and date the claim was resolved', `resolved_by` int(11) DEFAULT NULL COMMENT 'ID of the staff member that resolved the claim', PRIMARY KEY (`id`), - UNIQUE KEY `issue_id` (`issue_id`), KEY `itemnumber` (`itemnumber`), KEY `rc_borrowers_ibfk` (`borrowernumber`), KEY `rc_created_by_ibfk` (`created_by`), --- a/installer/data/mysql/mandatory/sysprefs.sql +++ a/installer/data/mysql/mandatory/sysprefs.sql @@ -115,6 +115,8 @@ INSERT INTO systempreferences ( `variable`, `value`, `options`, `explanation`, ` ('BorrowerUnwantedField','',NULL,'Name the fields you don\'t need to store for a patron\'s account','free'), ('BranchTransferLimitsType','ccode','itemtype|ccode','When using branch transfer limits, choose whether to limit by itemtype or collection code.','Choice'), ('BrowseResultSelection','0',NULL,'Enable/Disable browsing search results fromt the bibliographic record detail page in staff interface','YesNo'), +('BundleLostValue','5',NULL,'Sets the LOST AV value that represents "Missing from bundle" as a lost value','Free'), +('BundleNotLoanValue','3',NULL,'Sets the NOT_LOAN AV value that represents "Added to bundle" as a not for loan value','Free'), ('CalculateFinesOnReturn','1','','Switch to control if overdue fines are calculated on return or not','YesNo'), ('CalculateFinesOnBackdate','1','','Switch to control if overdue fines are calculated on return when backdating','YesNo'), ('CalendarFirstDayOfWeek','0','0|1|2|3|4|5|6','Select the first day of week to use in the calendar.','Choice'), --- a/koha-tmpl/intranet-tmpl/prog/css/src/_tables.scss +++ a/koha-tmpl/intranet-tmpl/prog/css/src/_tables.scss @@ -359,7 +359,7 @@ tbody { border-right: 1px solid $table-border-color; } } - &:nth-child(odd):not(.dtrg-group):not(.active) { + &:nth-child(odd):not(.dtrg-group):not(.active):not(.ok) { td { &:not(.bg-danger):not(.bg-warning):not(.bg-info):not(.bg-success):not(.bg-primary) { background-color: $table-odd-row; --- a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss +++ a/koha-tmpl/intranet-tmpl/prog/css/src/staff-global.scss @@ -1973,6 +1973,12 @@ i { font-style: italic; } +// style for bundled detail in catalogsearch +.bundled { + display: block; + font-style: italic; +} + #menu { border-right: 1px solid #B9D8D9; margin-right: .5em; @@ -2355,6 +2361,14 @@ td { display: block; } +.bundled { + display: block; +} + +td.bundle { + background-color: #FFC !important; +} + .datedue { color: #999; display: block; --- a/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers.inc @@ -115,6 +115,8 @@ [% ELSE %] [% IF subfield.IS_LOST_AV && Koha.Preference("ClaimReturnedLostValue") && aval == Koha.Preference("ClaimReturnedLostValue") %] + [% ELSIF subfield.IS_LOST_AV && Koha.Preference("BundleLostValue") && aval == Koha.Preference("BundleLostValue") %] + [% ELSE %] [% END %] --- a/koha-tmpl/intranet-tmpl/prog/en/includes/js-biblio-format.inc +++ a/koha-tmpl/intranet-tmpl/prog/en/includes/js-biblio-format.inc @@ -0,0 +1,64 @@ + --- 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/circulation.pref +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/admin/preferences/circulation.pref @@ -1284,6 +1284,17 @@ Circulation: - pref: ArticleRequestsSupportedFormats - "(Valid choices are currently: PHOTOCOPY and SCAN. Separate the supported formats by a vertical bar. The first listed format is selected by default when you request via the OPAC.)" + + Item bundles: + - + - Use the LOST authorised value + - pref: BundleLostValue + - to represent 'missing from bundle' at return. + - + - Use the NOT_LOAN authorised value + - pref: BundleNotLoanValue + - to represent 'added to bundle' when an item is attached to bundle. + Return claims: - - When marking a checkout as "claims returned", --- a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/catalogue/detail.tt @@ -368,12 +368,12 @@ [% IF ( analyze ) %]Used in[% END %] [% IF ( ShowCourseReserves ) %]Course reserves[% END %] [% IF ( SpineLabelShowPrintOnBibDetails ) %]Spine label[% END %] - [% IF ( CAN_user_editcatalogue_edit_items ) %] [% END %] + [% IF ( CAN_user_editcatalogue_edit_items ) %] [% END %] [% FOREACH item IN items %] - + [% IF (StaffDetailItemSelection) %] @@ -545,6 +545,11 @@ Note that permanent location is a code, and location may be an authval. [% IF ( item.restricted ) %] ([% 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.issues || 0 | html %] @@ -631,6 +636,9 @@ Note that permanent location is a code, and location may be an authval. Edit [% END %] [% END %] + [% IF bundlesEnabled %] + + [% END %] [% END %] @@ -1222,6 +1230,66 @@ Note that permanent location is a code, and location may be an authval. + [% IF bundlesEnabled %] + + + + [% END %] + [% MACRO jsinclude BLOCK %] [% INCLUDE 'catalog-strings.inc' %] [% Asset.js("js/catalog.js") | $raw %] @@ -1528,6 +1596,9 @@ 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' %] + [% INCLUDE 'js-patron-format.inc' %] + [% INCLUDE 'js-biblio-format.inc' %] [% Asset.js("js/browser.js") | $raw %] [% Asset.js("js/table_filters.js") | $raw %] [% Asset.js("js/pages/circulation.js") | $raw %] [% Asset.js("js/checkouts.js") | $raw %] - [% IF Koha.Preference('ClaimReturnedLostValue') %] + [% IF Koha.Preference('ClaimReturnedLostValue') || Koha.Preference('BundleLostValue') %] [% Asset.js("js/resolve_claim_modal.js") | $raw %] [% END %] [% Asset.js("js/holds.js") | $raw %] --- a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt +++ a/koha-tmpl/intranet-tmpl/prog/en/modules/circ/returns.tt @@ -6,6 +6,7 @@ [% USE ItemTypes %] [% USE AuthorisedValues %] [% USE TablesSettings %] +[% PROCESS 'i18n.inc' %] [% PROCESS 'member-display-address-style.inc' %] [% SET footerjs = 1 %] [% BLOCK display_bormessagepref %] @@ -217,6 +218,39 @@ [% END %] + + [% IF missing_items %] +
+

Bundle had missing items

+

Bundle contents list updated

+

+ View updated contents list + View list of missing items +

+
+ [% END %] + + + [% IF unexpected_items %] +
+

Bundle had unexpected items

+

Please place the following items to one side

+
    + [% FOREACH unexpected_item IN unexpected_items %] +
  • [% INCLUDE 'biblio-title.inc' biblio=unexpected_item.biblio %] - [% unexpected_item.barcode | html %]
  • + [% END %] +
+
+ [% END %] + + + [% IF InBundle %] +
+

Item belongs in bundle

+

This item belongs to a bundle: [% INCLUDE 'biblio-title.inc' biblio=InBundle.biblio %] - [% InBundle.barcode | html %]

+

+
+ [% END %] [% IF ( errmsgloop ) %]
@@ -347,6 +381,14 @@

[% checkinmsg | html_line_break %]

[% END # /IF checkinmsg %] + + [% IF bundle_items && !missing_items %] +
+

Bundle verified

+

The bundle content was verified

+

View contents list

+
+ [% END %] [% END # /BLOCK all_checkin_messages %] [% IF needs_confirm %] @@ -381,6 +423,91 @@ [% END %] + [% IF items_bundle_return_confirmation %] + + [% END %] + [% IF wrongbranch %]