From 2d5d29e1647d6a2dc3d3bb22c77f285383c01f8d Mon Sep 17 00:00:00 2001 From: Baptiste Wojtkowski Date: Thu, 31 Jul 2025 12:26:23 +0000 Subject: [PATCH] Bug 37334: [24.11] Allow filter holdings table by status (squashed) --- Koha/Item.pm | 49 ++ Koha/Items.pm | 198 ++++++++ api/v1/swagger/definitions/item.yaml | 7 +- api/v1/swagger/paths/biblios.yaml | 3 +- .../tables/items/catalogue_detail.inc | 57 ++- koha-tmpl/intranet-tmpl/prog/js/datatables.js | 9 +- t/db_dependent/Koha/Item.t | 140 +++++- t/db_dependent/Koha/Items.t | 469 +++++++++++++++++- 8 files changed, 900 insertions(+), 32 deletions(-) diff --git a/Koha/Item.pm b/Koha/Item.pm index 0de5531f1e..c98bdd5f17 100644 --- a/Koha/Item.pm +++ b/Koha/Item.pm @@ -1443,6 +1443,55 @@ sub columns_to_str { return $values; } +sub _status { + my ($self) = @_; + + my @statuses; + if ( my $checkout = $self->checkout ) { + unless ( $checkout->onsite_checkout ) { + push @statuses, "checked_out"; + } else { + push @statuses, "local_use"; + } + } elsif ( my $transfer = $self->transfer ) { + push @statuses, "in_transit"; + } + if ( $self->itemlost ) { + push @statuses, 'lost'; + } + if ( $self->withdrawn ) { + push @statuses, 'withdrawn'; + } + if ( $self->damaged ) { + push @statuses, 'damaged'; + } + if ( $self->notforloan || $self->item_type->notforloan ) { + + # TODO on a big Koha::Items loop we are going to join with item_type too often, use a cache + push @statuses, 'not_for_loan'; + } + if ( $self->first_hold ) { + push @statuses, 'on_hold'; + } + if ( C4::Context->preference('UseRecalls') && $self->recall ) { + push @statuses, 'recalled'; + } + + unless (@statuses) { + push @statuses, 'available'; + } + + if ( $self->restricted ) { + push @statuses, 'restricted'; + } + + if ( $self->in_bundle ) { + push @statuses, 'in_bundle'; + } + + return join ',', @statuses; +} + =head3 additional_attributes my $attributes = $item->additional_attributes; diff --git a/Koha/Items.pm b/Koha/Items.pm index 1cb1dce1b5..d7744cbbc6 100644 --- a/Koha/Items.pm +++ b/Koha/Items.pm @@ -222,6 +222,148 @@ sub filter_by_bookable { ); } +=head3 filter_by_checked_out + + my $checked_out_items = $items->filter_by_checked_out; + +Returns a new resultset, containing only those items that are currently checked out. + +=cut + +sub filter_by_checked_out { + my ( $self, $params ) = @_; + + $params //= {}; + my $checkouts = Koha::Checkouts->search( + { %$params, 'me.itemnumber' => [ $self->get_column('itemnumber') ], }, + { + columns => ['itemnumber'], + distinct => 1 + } + )->_resultset->as_query; + + return $self->search( { 'me.itemnumber' => { '-in' => $checkouts } } ); +} + +=head3 filter_by_in_transit + + my $in_tranist_items = $items->filter_by_in_transit; + +Returns a new resultset, containing only those items that are currently in transit. + +=cut + +sub filter_by_in_transit { + my ( $self, $params ) = @_; + + $params //= {}; + my $transfers = Koha::Item::Transfers->search( + { %$params, 'me.itemnumber' => [ $self->get_column('itemnumber') ], }, + { + columns => ['itemnumber'], + distinct => 1 + } + )->_resultset->as_query; + + return $self->search( { 'me.itemnumber' => { '-in' => $transfers } } ); +} + +=head3 filter_by_has_holds + + my $has_hold_items = $items->filter_by_has_holds; + +Returns a new resultset, containing only those items that currently have holds. + +=cut + +sub filter_by_has_holds { + my ( $self, $params ) = @_; + + $params //= {}; + my $holds = Koha::Holds->search( + { %$params, 'me.itemnumber' => [ $self->get_column('itemnumber') ], }, + { + columns => ['itemnumber'], + distinct => 1 + } + )->_resultset->as_query; + + return $self->search( { 'me.itemnumber' => { '-in' => $holds } } ); +} + +=head3 filter_by_has_recalls + + my $has_recalls_items = $items->filter_by_has_recalls; + +Returns a new resultset, containing only those items that currently have recalls. + +=cut + +sub filter_by_has_recalls { + my ( $self, $params ) = @_; + + $params //= {}; + my $recalls = Koha::Recalls->search( + { %$params, 'me.itemnumber' => [ $self->get_column('itemnumber') ], 'me.item_level' => 1, }, + { + columns => ['itemnumber'], + distinct => 1 + } + )->_resultset->as_query; + return $self->search( { 'me.itemnumber' => { '-in' => $recalls } } ); +} + +=head3 filter_by_in_bundle + +Returns a new resultset, containing only those items that currently are part of a bundle. + +=cut + +sub filter_by_in_bundle { + my ($self) = @_; + + my @in_bundle_items; + while ( my $item = $self->next ) { + push @in_bundle_items, $item if $item->in_bundle; + } + + my @bundled_items = map { $_->itemnumber } @in_bundle_items; + return $self->search( { 'me.itemnumber' => { '-in' => \@bundled_items } } ); +} + +=head3 filter_by_available + + my $available_items = $items->filter_by_available; + +Returns a new resultset, containing only those items that are currently available. + +=cut + +sub filter_by_available { + my ($self) = @_; + + my @all_itemnumbers = $self->get_column('itemnumber'); + my @not_available_itemnumbers; + push @not_available_itemnumbers, $self->filter_by_checked_out->get_column('itemnumber'); + push @not_available_itemnumbers, $self->filter_by_in_transit->get_column('itemnumber'); + + push @not_available_itemnumbers, $self->filter_by_has_holds->get_column('itemnumber'); + push @not_available_itemnumbers, $self->filter_by_has_recalls->get_column('itemnumber'); + + my @item_types_notforloan = Koha::ItemTypes->search( { notforloan => { '!=' => 0 } } )->get_column('itemtype'); + return Koha::Items->search( + { + 'me.itemnumber' => [ array_minus @all_itemnumbers, @not_available_itemnumbers ], + itemlost => 0, + withdrawn => 0, + damaged => 0, + notforloan => 0, + restricted => [ { '!=' => 0 }, undef ], + 'me.itype' => { -not_in => \@item_types_notforloan }, + } + ); +} + =head3 move_to_biblio $items->move_to_biblio($to_biblio); @@ -485,6 +627,62 @@ sub apply_regex { return $value; } +=head3 search + + my $search_result = $object->search( $params, $attributes ); + +Filters items based on the specified status. + +=cut + +sub search { + my ( $self, $params, $attributes ) = @_; + my $status = ( $params && ref($params) eq 'HASH' ) ? delete $params->{_status} : undef; + if ($status) { + if ( $status eq 'checked_out' ) { + $self = $self->filter_by_checked_out( { onsite_checkout => 0 } ); + } + if ( $status eq 'local_use' ) { + $self = $self->filter_by_checked_out( { onsite_checkout => 1 } ); + } + if ( $status eq 'in_transit' ) { + $self = $self->filter_by_in_transit; + } + if ( $status eq 'lost' ) { + $self = $self->search( { itemlost => { '!=' => 0 } } ); + } + if ( $status eq 'withdrawn' ) { + $self = $self->search( { withdrawn => { '!=' => 0 } } ); + } + if ( $status eq 'damaged' ) { + $self = $self->search( { damaged => { '!=' => 0 } } ); + } + if ( $status eq 'not_for_loan' ) { + my @item_types_notforloan = + Koha::ItemTypes->search( { notforloan => { '!=' => 0 } } )->get_column('itemtype'); + $self = $self->search( [ { notforloan => { '!=' => 0 } }, { 'me.itype' => \@item_types_notforloan } ] ); + } + if ( $status eq 'on_hold' ) { + $self = $self->filter_by_has_holds; + } + if ( $status eq 'recalled' ) { + $self = $self->filter_by_has_recalls; + } + if ( $status eq 'in_bundle' ) { + $self = $self->filter_by_in_bundle; + } + + if ( $status eq 'available' ) { + $self = $self->filter_by_available; + } + + if ( $status eq 'restricted' ) { + $self = $self->search( { restricted => [ { '!=' => 0 } ] } ); + } + } + return $self->SUPER::search( $params, $attributes ); +} + =head3 search_ordered $items->search_ordered; diff --git a/api/v1/swagger/definitions/item.yaml b/api/v1/swagger/definitions/item.yaml index b254eb2dc3..4df212b8db 100644 --- a/api/v1/swagger/definitions/item.yaml +++ b/api/v1/swagger/definitions/item.yaml @@ -322,4 +322,9 @@ properties: - object - "null" description: A return claims object if one exists that's unresolved -additionalProperties: false + _status: + type: + - string + - "null" + description: The status of the item +additionalProperties: false \ No newline at end of file diff --git a/api/v1/swagger/paths/biblios.yaml b/api/v1/swagger/paths/biblios.yaml index 4f301fc911..ab9f8ebd45 100644 --- a/api/v1/swagger/paths/biblios.yaml +++ b/api/v1/swagger/paths/biblios.yaml @@ -458,6 +458,7 @@ type: string enum: - +strings + - _status - home_library - holding_library - biblio.title @@ -918,4 +919,4 @@ "503": description: Under maintenance schema: - $ref: "../swagger.yaml#/definitions/error" + $ref: "../swagger.yaml#/definitions/error" \ No newline at end of file diff --git a/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers/tables/items/catalogue_detail.inc b/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers/tables/items/catalogue_detail.inc index 771de2ea12..9215daeeb6 100644 --- a/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers/tables/items/catalogue_detail.inc +++ b/koha-tmpl/intranet-tmpl/prog/en/includes/html_helpers/tables/items/catalogue_detail.inc @@ -220,6 +220,9 @@ }); const item_types_notforloan = new Map(all_item_types.map( it => [it.itemtype, it.notforloan] )); + const statuses = {checked_out: _("Checked out"), local_use: _("On-site checkout"), in_transit: _("In transit"), lost: _("Lost"), withdrawn: _("Withdrawn"), damaged:_("Damaged"), not_for_loan: _("Not for loan"), on_hold: _("On hold"), recalled: _("Recalled"), available: _("Available"), restricted: _("Restricted"), in_bundle: _("In bundle")}; + const all_statuses = Object.keys(statuses).map(k => {return {_id: k, _str: statuses[k]}}).sort(); + const can_edit_items_from = [% To.json(can_edit_items_from || []) | $raw %]; const item_type_image_locations = [% To.json(item_type_image_locations) | $raw %]; const av_loc = new Map([% To.json(AuthorisedValues.Get('LOC')) | $raw %].map( av => [av.authorised_value, av.lib])); @@ -247,7 +250,7 @@ [%# In case or SeparateHoldings we may need to display the number of biblios in each tab %] [%# Do we need separate/new endpoints or do we hack the somewhere client-side? %] let item_table_url = "/api/v1/biblios/[% biblio.biblionumber | uri %]/items?"; - let embed = ["+strings,checkout,checkout.patron,transfer,transfer+strings,first_hold,first_hold+strings,first_hold.patron,first_hold.desk"]; + let embed = ["+strings,_status,checkout,checkout.patron,transfer,transfer+strings,first_hold,first_hold+strings,first_hold.patron,first_hold.desk"]; [% IF Koha.Preference('LocalCoverImages') %] embed.push('cover_image_ids'); [% END %] @@ -319,12 +322,17 @@ items_selection[tab_id] = []; } + default_filters._status = function(){ + return $("#" + tab_id + "_status select").val(); + }; + let offset = 2; [% UNLESS Koha.Preference('LocalCoverImages') %]offset--;[% END %] let filters_options = { [offset] : () => all_item_types, [offset+1] : () => all_libraries, [offset+2] : () => all_libraries, + [offset+6] : () => all_statuses, }; var items_table = $("#" + tab_id + '_table').kohaTable({ @@ -490,29 +498,31 @@ } }, { - data: "me.lost_status", + data: "", className: "status", - searchable: false, // FIXME We are losing the ability to search on the status + searchable: false, orderable: false, render: function (data, type, row, meta) { let nodes = ""; - if ( row.checkout ) { + row._status.split(",").forEach( status => { + if ( status == 'checked_out' || status == 'local_use') { + nodes += ''; + [%# Hacky for patron_to_html in case we simply want to display the patron's library name %] row.checkout.patron.library = { name: libraries_names.get(row.checkout.patron.library_id) }; + let patron_to_html = $patron_to_html(row.checkout.patron, { url: true, display_cardnumber: true, hide_patron_name }); - nodes += ''; - if ( row.checkout.onsite_checkout ) { - let patron_to_html = $patron_to_html(row.checkout.patron, { url: true, display_cardnumber: true, hide_patron_name }); + if ( status == 'local_use' ) { nodes += _("Currently in local use by %s").format(patron_to_html); } else { nodes += ''; - let patron_to_html = $patron_to_html(row.checkout.patron, { url: true, display_cardnumber: true, hide_patron_name }); nodes += _("Checked out to %s").format(patron_to_html); } nodes += ': '; nodes += _("due %s").format($date(row.checkout.due_date, { as_due_date: true })); nodes += "" - } else if ( row.transfer ) { + } + if ( status == 'in_transit' ) { if ( row.transfer.datesent ) { nodes += '%s'.format(_("In transit from %s to %s since %s").format(escape_str(row.transfer._strings.from_library.str), escape_str(row.transfer._strings.to_library.str), $date(row.transfer.datesent))); } else { @@ -520,33 +530,31 @@ } } - if ( row.lost_status ) { + if ( status == 'lost' ) { let lost_lib = av_lost.get(row.lost_status.toString()) || _("Unavailable (lost or missing"); nodes += '%s'.format(escape_str(lost_lib)); - [% IF Koha.Preference('ClaimReturnedLostValue') %] - const hasReturnClaims = row.return_claims.filter(rc => !rc.resolution).length > 0 ? true : false - if(hasReturnClaims) { - nodes += '' + _("(Claimed returned)") + ''; - } - [% END %] + const hasReturnClaims = row.return_claims && row.return_claims.filter(rc => !rc.resolution).length > 0 ? true : false + if(hasReturnClaims) { + nodes += '' + _("(Claimed returned)") + ''; + } } - if ( row.withdrawn ) { + if ( status == 'withdrawn' ) { let withdrawn_lib = av_withdrawn.get(row.withdrawn.toString()) || _("Withdrawn"); nodes += '%s'.format(escape_str(withdrawn_lib)); } - if ( row.damaged_status ) { + if ( status == 'damaged' ) { let damaged_lib = av_damaged.get(row.damaged_status.toString()) || _("Damaged"); nodes += '%s'.format(escape_str(damaged_lib)); } - if ( row.not_for_loan_status || item_types_notforloan.get(row.item_type_id) ) { + if ( status == 'not_for_loan' ) { let not_for_loan_lib = av_not_for_loan.get(row.not_for_loan_status.toString()); nodes += '%s'.format(_("Not for loan")) + ( not_for_loan_lib ? ' (%s)'.format(escape_str(not_for_loan_lib)) : '' ) + ''; } - if ( row.first_hold ) { + if ( status == 'on_hold') { if ( row.first_hold.waiting_date ) { if ( row.first_hold.desk ) { nodes += '%s'.format(_("Waiting at %s, %s since %s.").format(row.first_hold._strings.pickup_library_id.str, row.first_hold.desk.desk_name, $date(row.first_hold.waiting_date))); @@ -580,18 +588,17 @@ } } [% END %] - - if ( ! ( row.not_for_loan_status || item_types_notforloan.get(row.item_type_id) || row.checked_out_date || row.lost_status || row.withdrawn || row.damaged_status || row.transfer || row.first_hold || ( row.recall && ( row.item_id === row.recall.item_id ) ) )) { + if ( status == 'available' ) { nodes += ' %s'.format(_("Available")) } - if ( row.restricted_status ) { + if ( status == 'restricted') { nodes += '(%s)'.format(escape_str(av_restricted.get(row.restricted_status.toString()))); } - - if ( row.in_bundle ) { + if ( status == 'in_bundle') { nodes += '%s'.format(_("In bundle: %s").format($biblio_to_html(row.bundle_host.biblio, { link: true }))); } + }); //end forEach return nodes; } }, diff --git a/koha-tmpl/intranet-tmpl/prog/js/datatables.js b/koha-tmpl/intranet-tmpl/prog/js/datatables.js index 154144f441..f0af7c86cf 100644 --- a/koha-tmpl/intranet-tmpl/prog/js/datatables.js +++ b/koha-tmpl/intranet-tmpl/prog/js/datatables.js @@ -606,7 +606,9 @@ function _dt_default_ajax (params){ } else if ( f == '-and' ) { if (v) and_query_parameters.push(v) } else if ( v ) { - additional_filters[k] = v; + additional_filters[k] = v + .replace(/^\^/, "") + .replace(/\$$/, ""); } } if ( Object.keys(additional_filters).length ) { @@ -883,9 +885,10 @@ function _dt_add_filters(table_node, table_dt, filters_options = {}) { let i = column.index(); var visible_i = table_dt.column.index('fromData', i); let th = $(table_node).find('thead tr:eq(1) th:eq(%s)'.format(visible_i)); - var is_searchable = columns[i].bSearchable; + var is_searchable = table_dt.settings()[0].aoColumns[i].bSearchable; $(th).removeClass('sorting').removeClass("sorting_asc").removeClass("sorting_desc"); - if ( is_searchable ) { + $(this).data("th-id", i); + if (is_searchable || $(this).data("filter") || filters_options[i]) { let input_type = 'input'; let existing_search = column.search(); if ( $(th).data('filter') || filters_options.hasOwnProperty(i)) { diff --git a/t/db_dependent/Koha/Item.t b/t/db_dependent/Koha/Item.t index c2778b5c8c..e8afaede53 100755 --- a/t/db_dependent/Koha/Item.t +++ b/t/db_dependent/Koha/Item.t @@ -20,7 +20,7 @@ use Modern::Perl; use utf8; -use Test::More tests => 38; +use Test::More tests => 39; use Test::Exception; use Test::MockModule; use Test::Warn; @@ -45,6 +45,144 @@ use t::lib::Dates; my $schema = Koha::Database->new->schema; my $builder = t::lib::TestBuilder->new; +subtest '_status' => sub { + plan tests => 12; + + $schema->storage->txn_begin; + + my $item = $builder->build_sample_item(); + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + t::lib::Mocks::mock_userenv( { branchcode => $library->branchcode } ); + + t::lib::Mocks::mock_preference( 'UseRecalls', 1 ); + + my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); + + my @test_cases = ( + { + setup => sub { + my $onloan_item = $builder->build_sample_item(); + AddIssue( $patron, $onloan_item->barcode, dt_from_string ); + return $onloan_item; + }, + expected_status => 'checked_out', + description => 'Checked out status correctly returned', + }, + { + setup => sub { + my $onsite_item = $builder->build_sample_item(); + AddIssue( + $patron, $onsite_item->barcode, dt_from_string, undef, undef, undef, + { onsite_checkout => 1 } + ); + return $onsite_item; + }, + expected_status => 'local_use', + description => 'Local use status correctly returned', + }, + { + setup => sub { + return $item; + }, + expected_status => 'available', + description => 'Available status correctly returned', + }, + { + setup => sub { + $item->itemlost(1)->store(); + return $item; + }, + expected_status => 'lost', + description => 'Lost status correctly returned', + }, + { + setup => sub { + $item->withdrawn(1)->store(); + return $item; + }, + expected_status => qr/lost,withdrawn/, + description => 'Lost and withdrawn status correctly returned', + }, + { + setup => sub { + $item->damaged(1)->store(); + return $item; + }, + expected_status => qr/lost,withdrawn,damaged/, + description => 'Lost, withdrawn, and damaged status correctly returned', + }, + { + setup => sub { + $item->notforloan(1)->store(); + return $item; + }, + expected_status => 'not_for_loan', + description => 'Positive not_for_loan status correctly returned', + }, + { + setup => sub { + $item->notforloan(-1)->store(); + return $item; + }, + expected_status => 'not_for_loan', + description => 'Negative not_for_loan status correctly returned', + }, + { + setup => sub { + my $itemtype = $builder->build_object( { class => "Koha::ItemTypes", value => { notforloan => 1 } } ); + my $notforloan_item = $builder->build_sample_item( { itype => $itemtype->itemtype, } ); + return $notforloan_item; + }, + expected_status => 'not_for_loan', + description => 'Item type not_for_loan status correctly returned', + }, + { + setup => sub { + my $onhold_item = $builder->build_sample_item(); + C4::Reserves::AddReserve( + { + branchcode => $library->branchcode, + borrowernumber => $patron->borrowernumber, + biblionumber => $onhold_item->biblionumber, + itemnumber => $onhold_item->itemnumber, + } + ); + return $onhold_item; + }, + expected_status => 'on_hold', + description => 'On hold status correctly returned', + }, + { + setup => sub { + my $recalled_item = $builder->build_sample_item(); + AddIssue( $patron, $recalled_item->barcode, dt_from_string ); + Koha::Recalls->add_recall( + { biblio => $recalled_item->biblio, item => $recalled_item, patron => $patron } ); + return $recalled_item; + }, + expected_status => 'recalled', + description => 'Recalled status correctly returned', + }, + { + setup => sub { + $item->restricted(1)->store(); + return $item; + }, + expected_status => 'restricted', + description => 'Restricted status correctly returned', + }, + ); + + foreach my $test_case (@test_cases) { + my $item = $test_case->{setup}->(); + ok( $item->_status() =~ /$test_case->{expected_status}/, $test_case->{description} ); + } + + t::lib::Mocks::mock_preference( 'UseRecalls', 0 ); + + $schema->storage->txn_rollback; +}; + subtest 'z3950_status' => sub { plan tests => 9; diff --git a/t/db_dependent/Koha/Items.t b/t/db_dependent/Koha/Items.t index 03b26550eb..038db7bc5b 100755 --- a/t/db_dependent/Koha/Items.t +++ b/t/db_dependent/Koha/Items.t @@ -19,7 +19,7 @@ use Modern::Perl; -use Test::More tests => 20; +use Test::More tests => 27; use Test::MockModule; use Test::Exception; @@ -34,6 +34,7 @@ use Koha::Items; use Koha::Database; use Koha::DateUtils qw( dt_from_string ); use Koha::Statistics; +use Koha::Recalls; use t::lib::TestBuilder; use t::lib::Mocks; @@ -68,6 +69,163 @@ is( Koha::Items->search->count, $nb_of_items + 2, 'The 2 items should have been my $retrieved_item_1 = Koha::Items->find( $new_item_1->itemnumber ); is( $retrieved_item_1->barcode, $new_item_1->barcode, 'Find a item by id should return the correct item' ); +subtest 'search' => sub { + + plan tests => 9; + $schema->storage->txn_begin; + + my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); + my $patron_2 = $builder->build_object( { class => 'Koha::Patrons' } ); + t::lib::Mocks::mock_userenv( { branchcode => $patron->branchcode } ); + + my $library_1 = $builder->build( { source => 'Branch' } ); + my $library_2 = $builder->build( { source => 'Branch' } ); + + my $biblio = $builder->build_sample_biblio(); + + my $item_1 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + my $item_2 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + + my $available_items = Koha::Items->search( + { + _status => 'available', + biblionumber => $biblio->biblionumber, + } + ); + + ok( $available_items->count == 2, "Filtered to 2 available items" ); + + my $item_3 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + itemlost => 1, + } + ); + + my $item_4 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + damaged => 1, + } + ); + + my $item_5 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + withdrawn => 1, + } + ); + + my $item_6 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + notforloan => 1, + } + ); + + my $lost_items = Koha::Items->search( + { + _status => 'lost', + biblionumber => $biblio->biblionumber, + } + ); + + my $damaged_items = Koha::Items->search( + { + _status => 'damaged', + biblionumber => $biblio->biblionumber, + } + ); + + my $withdrawn_items = Koha::Items->search( + { + _status => 'withdrawn', + biblionumber => $biblio->biblionumber, + } + ); + + my $notforloan_items = Koha::Items->search( + { + _status => 'not_for_loan', + biblionumber => $biblio->biblionumber, + } + ); + + ok( $lost_items->count == 1, "Filtered to 1 lost item" ); + ok( $damaged_items->count == 1, "Filtered to 1 damaged item" ); + ok( $withdrawn_items->count == 1, "Filtered to 1 withdrawn item" ); + ok( $notforloan_items->count == 1, "Filtered to 1 notforloan item" ); + + C4::Circulation::AddIssue( $patron, $item_1->barcode ); + + my $checked_out_items = Koha::Items->search( + { + _status => 'checked_out', + biblionumber => $biblio->biblionumber, + } + ); + + ok( $checked_out_items->count == 1, "Filtered to 1 checked out item" ); + + my $transfer_1 = $builder->build_object( + { + class => 'Koha::Item::Transfers', + value => { + itemnumber => $item_2->itemnumber, + frombranch => $library_1->{branchcode}, + tobranch => $library_2->{branchcode}, + } + } + ); + + my $in_transit_items = Koha::Items->search( + { + _status => 'in_transit', + biblionumber => $biblio->biblionumber, + } + ); + + ok( $in_transit_items->count == 1, "Filtered to 1 in transit item" ); + + my $item_7 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + + my $hold_1 = $builder->build( + { + source => 'Reserve', + value => { + itemnumber => $item_7->itemnumber, reservedate => dt_from_string, + } + } + ); + + my $on_hold_items = Koha::Items->search( + { + _status => 'on_hold', + biblionumber => $biblio->biblionumber, + } + ); + + ok( $on_hold_items->count == 1, "Filtered to 1 on hold item" ); + + my $item_8 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + restricted => 1, + } + ); + + my $restricted_items = Koha::Items->search( + { + _status => 'restricted', + biblionumber => $biblio->biblionumber, + } + ); + + ok( $restricted_items->count == 1, "Filtered to 1 restricted item" ); + + $schema->storage->txn_rollback; +}; + subtest 'store' => sub { plan tests => 8; @@ -2222,3 +2380,312 @@ subtest 'filter_by_bookable' => sub { $schema->storage->txn_rollback; }; + +subtest 'filter_by_checked_out' => sub { + plan tests => 4; + + $schema->storage->txn_begin; + + my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); + t::lib::Mocks::mock_userenv( { branchcode => $patron->branchcode } ); + + my $biblio = $builder->build_sample_biblio(); + my $item_1 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + my $item_2 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + + is( $biblio->items->filter_by_checked_out->count, 0, "Filtered 0 checked out items" ); + + C4::Circulation::AddIssue( $patron, $item_1->barcode ); + + is( $biblio->items->filter_by_checked_out->count, 1, "Filtered 1 checked out items" ); + + C4::Circulation::AddIssue( $patron, $item_2->barcode ); + + is( $biblio->items->filter_by_checked_out->count, 2, "Filtered 2 checked out items" ); + + # Do the returns + C4::Circulation::AddReturn( $item_1->barcode ); + C4::Circulation::AddReturn( $item_2->barcode ); + + is( $biblio->items->filter_by_checked_out->count, 0, "Filtered 0 checked out items" ); + + $schema->storage->txn_rollback; + +}; + +subtest 'filter_by_in_transit' => sub { + plan tests => 3; + + $schema->storage->txn_begin; + + my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); + t::lib::Mocks::mock_userenv( { branchcode => $patron->branchcode } ); + + my $library_1 = $builder->build( { source => 'Branch' } ); + my $library_2 = $builder->build( { source => 'Branch' } ); + + my $biblio = $builder->build_sample_biblio(); + my $item_1 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + my $item_2 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + + is( $biblio->items->filter_by_in_transit->count, 0, "Filtered 0 in transit items" ); + + my $transfer_1 = $builder->build_object( + { + class => 'Koha::Item::Transfers', + value => { + itemnumber => $item_1->itemnumber, + frombranch => $library_1->{branchcode}, + tobranch => $library_2->{branchcode}, + } + } + ); + + is( $biblio->items->filter_by_in_transit->count, 1, "Filtered 1 in transit items" ); + + my $transfer_2 = $builder->build_object( + { + class => 'Koha::Item::Transfers', + value => { + itemnumber => $item_2->itemnumber, + frombranch => $library_2->{branchcode}, + tobranch => $library_1->{branchcode}, + } + } + ); + + is( $biblio->items->filter_by_in_transit->count, 2, "Filtered 2 in transit items" ); + + $schema->storage->txn_rollback; + +}; + +subtest 'filter_by_has_holds' => sub { + plan tests => 3; + + $schema->storage->txn_begin; + + my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); + t::lib::Mocks::mock_userenv( { branchcode => $patron->branchcode } ); + + my $library_1 = $builder->build( { source => 'Branch' } ); + my $library_2 = $builder->build( { source => 'Branch' } ); + + my $biblio = $builder->build_sample_biblio(); + my $item_1 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + my $item_2 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + + is( $biblio->items->filter_by_has_holds->count, 0, "Filtered to 0 holds" ); + + my $hold_1 = $builder->build( + { + source => 'Reserve', + value => { + itemnumber => $item_1->itemnumber, reservedate => dt_from_string, + } + } + ); + + is( $biblio->items->filter_by_has_holds->count, 1, "Filtered to 1 hold" ); + + my $hold_2 = $builder->build( + { + source => 'Reserve', + value => { + itemnumber => $item_2->itemnumber, reservedate => dt_from_string, + } + } + ); + + is( $biblio->items->filter_by_has_holds->count, 2, "Filtered to 2 holds" ); + + $schema->storage->txn_rollback; + +}; + +subtest 'filter_by_in_bundle' => sub { + plan tests => 3; + + $schema->storage->txn_begin; + + my $library = $builder->build( { source => 'Branch' } ); + my $biblio = $builder->build_sample_biblio(); + + my $item_1 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + my $item_2 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + my $item_3 = $builder->build_sample_item( { biblionumber => $biblio->biblionumber, } ); + + is( $biblio->items->filter_by_in_bundle->count, 0, "0 items in a bundle for this record" ); + + my $in_bundle = $item_1->in_bundle; + + my $host_item = $builder->build_sample_item(); + $schema->resultset('ItemBundle')->create( { host => $host_item->itemnumber, item => $item_1->itemnumber } ); + + $in_bundle = $item_1->in_bundle; + + is( $biblio->items->filter_by_in_bundle->count, 1, "1 item in a bundle for this record" ); + $schema->resultset('ItemBundle')->create( { host => $host_item->itemnumber, item => $item_2->itemnumber } ); + + $in_bundle = $item_2->in_bundle; + + is( $biblio->items->filter_by_in_bundle->count, 2, "2 items in a bundle for this record" ); + + $schema->storage->txn_rollback; + +}; + +subtest 'filter_by_has_recalls' => sub { + plan tests => 2; + + $schema->storage->txn_begin; + + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + + $biblio = $builder->build_sample_biblio( { author => 'Hall, Daria' } ); + my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); + t::lib::Mocks::mock_userenv( { branchcode => $patron->branchcode } ); + + my $item = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + library => $library->branchcode, + } + ); + + C4::Circulation::AddIssue( $patron, $item->barcode ); + + is( $biblio->items->filter_by_has_recalls->count, 0, "0 items with recalls on this record" ); + + Koha::Recalls->add_recall( { biblio => $item->biblio, item => $item, patron => $patron } ); + + is( $biblio->items->filter_by_has_recalls->count, 1, "1 item with recalls on this record" ); + + $schema->storage->txn_rollback; + +}; + +subtest 'filter_by_available' => sub { + plan tests => 6; + + $schema->storage->txn_begin; + + my $library = $builder->build_object( { class => 'Koha::Libraries' } ); + my $biblio = $builder->build_sample_biblio(); + my $patron = $builder->build_object( { class => 'Koha::Patrons' } ); + t::lib::Mocks::mock_userenv( { branchcode => $patron->branchcode } ); + + my $item_1 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + library => $library->branchcode, + itemlost => 0, + withdrawn => 0, + damaged => 0, + notforloan => 0, + onloan => undef, + } + ); + + my $item_2 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + library => $library->branchcode, + itemlost => 0, + withdrawn => 0, + damaged => 0, + notforloan => 0, + onloan => undef, + } + ); + + my $item_3 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + library => $library->branchcode, + itemlost => 0, + withdrawn => 0, + damaged => 0, + notforloan => 0, + onloan => undef, + } + ); + + my $item_4 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + library => $library->branchcode, + itemlost => 0, + withdrawn => 0, + damaged => 0, + notforloan => 0, + onloan => undef, + } + ); + + my $item_5 = $builder->build_sample_item( + { + biblionumber => $biblio->biblionumber, + library => $library->branchcode, + itemlost => 0, + withdrawn => 0, + damaged => 0, + notforloan => 0, + onloan => undef, + } + ); + + # Create items with varying states + # Test: Initial available items + is( + $biblio->items->filter_by_available->count, + 5, + "Filtered to 4 available items" + ); + + # Mark item_1 as lost + $item_1->itemlost(3)->store; + C4::Circulation::LostItem( $item_1->itemnumber, 1 ); + + is( + $biblio->items->filter_by_available->count, + 4, + "Filtered to 4 available items, 1 is lost" + ); + + #Mark item_2 as damaged + $item_2->damaged(1)->store; + + is( + $biblio->items->filter_by_available->count, + 3, + "Filtered to 3 available items, 1 is lost, 1 is damaged" + ); + + #Mark item_3 as withdrawn + $item_3->withdrawn(1)->store; + + is( + $biblio->items->filter_by_available->count, + 2, + "Filtered to 2 available items, 1 is lost, 1 is damaged, 1 is withdrawn" + ); + + #Checkout item_4 + C4::Circulation::AddIssue( $patron, $item_4->barcode ); + is( + $biblio->items->filter_by_available->count, + 1, + "Filtered to 1 available items, 1 is lost, 1 is damaged, 1 is withdrawn, 1 is checked out" + ); + + #Mark item_5 as notforloan + $item_5->notforloan(1)->store; + is( + $biblio->items->filter_by_available->count, + 0, + "Filtered to 0 available items, 1 is lost, 1 is damaged, 1 is withdrawn, 1 is checked out, 1 is notforloan" + ); + + $schema->storage->txn_rollback; +}; -- 2.43.0